frutebox-node 0.1.0__py3-none-any.whl
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.
- frutebox/adminheader.py +59 -0
- frutebox/attest.py +43 -0
- frutebox/audiogen.py +364 -0
- frutebox/config.py +367 -0
- frutebox/deltasync.py +211 -0
- frutebox/economics.py +270 -0
- frutebox/engine_guard.py +718 -0
- frutebox/envelope.py +77 -0
- frutebox/envvars.py +167 -0
- frutebox/gpuprices.py +641 -0
- frutebox/gpuproviders.py +4489 -0
- frutebox/gui.py +270 -0
- frutebox/imagegen.py +152 -0
- frutebox/inference.py +624 -0
- frutebox/manifest.py +218 -0
- frutebox/mediaserving.py +603 -0
- frutebox/packaging.py +542 -0
- frutebox/pqctransport.py +660 -0
- frutebox/provider.py +1867 -0
- frutebox/providers.py +942 -0
- frutebox/registry/__init__.py +189 -0
- frutebox/registry/generate.py +311 -0
- frutebox/registry/generate_providers.py +120 -0
- frutebox/registry/models.yaml +1759 -0
- frutebox/registry/providers.yaml +518 -0
- frutebox/registry/registry.gen.json +2043 -0
- frutebox/registry_gen.py +3 -0
- frutebox/runpod_templates.py +143 -0
- frutebox/serving.py +691 -0
- frutebox/signedconfig.py +259 -0
- frutebox/signing.py +208 -0
- frutebox/thinkbudget.py +155 -0
- frutebox/videogen.py +118 -0
- frutebox_node-0.1.0.dist-info/METADATA +100 -0
- frutebox_node-0.1.0.dist-info/RECORD +36 -0
- frutebox_node-0.1.0.dist-info/WHEEL +4 -0
frutebox/gpuproviders.py
ADDED
|
@@ -0,0 +1,4489 @@
|
|
|
1
|
+
"""GPU providers — one contract, many clouds, no lock-in.
|
|
2
|
+
|
|
3
|
+
WHY THIS EXISTS IN THIS SHAPE
|
|
4
|
+
On 2026-08-21 a pod billed for hours because wake created it under one name and stop looked
|
|
5
|
+
for another. That was not a typo in a helper — it was an ASYMMETRY that the code's shape
|
|
6
|
+
allowed: `start` and `stop` were written separately per provider, so nothing forced them to
|
|
7
|
+
agree on how an instance is identified.
|
|
8
|
+
|
|
9
|
+
Adding more providers multiplies that risk, and the risk is worse than it looks because every
|
|
10
|
+
provider spells the label differently: RunPod and Lambda call it `name`, Vast.ai calls it
|
|
11
|
+
`label`. A per-provider `start`/`stop` pair is three more chances to get it wrong, silently,
|
|
12
|
+
in a direction that costs money and reports success.
|
|
13
|
+
|
|
14
|
+
So a provider does NOT implement start/stop. It implements three narrow operations:
|
|
15
|
+
|
|
16
|
+
find(label) -> the live instances carrying this label
|
|
17
|
+
create(label, spec) -> create ONE instance carrying this label
|
|
18
|
+
destroy(id) -> terminate it
|
|
19
|
+
|
|
20
|
+
and `start`/`stop` are written ONCE, here, on top of them. The symmetry is then structural:
|
|
21
|
+
both sides go through the same `find(label)`, so they cannot disagree about what to look for.
|
|
22
|
+
`contract_violations()` and the shared test suite check each provider actually honours it.
|
|
23
|
+
|
|
24
|
+
WHAT A PROVIDER MUST GET RIGHT
|
|
25
|
+
`create` MUST apply the label the caller passed, not one from its own spec. That single rule is
|
|
26
|
+
the whole bug. It is stated on the method, tested for every provider, and is the first thing to
|
|
27
|
+
check when adding a fourth.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
import time
|
|
33
|
+
import contextlib
|
|
34
|
+
import contextvars
|
|
35
|
+
import hashlib
|
|
36
|
+
import hmac
|
|
37
|
+
import json
|
|
38
|
+
import logging
|
|
39
|
+
import uuid
|
|
40
|
+
from typing import Awaitable, Callable, Dict, List, Optional, Protocol, Set
|
|
41
|
+
from urllib.parse import quote as _urlquote
|
|
42
|
+
|
|
43
|
+
import httpx
|
|
44
|
+
|
|
45
|
+
from .envvars import env as envvar
|
|
46
|
+
|
|
47
|
+
log = logging.getLogger("frutebox.gpuproviders")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Instance(dict):
|
|
51
|
+
"""A live instance: at minimum ``{"id": str, "label": str}``."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GpuProvider(Protocol):
|
|
55
|
+
"""Three required operations, plus two OPTIONAL enumerations. start/stop are NOT part of
|
|
56
|
+
this — they are derived.
|
|
57
|
+
|
|
58
|
+
WHY ENUMERATION IS THREE METHODS AND NOT ONE (owner ruling, 2026-09-24). "Is this slot
|
|
59
|
+
serving?" and "is anything of mine still billing here?" are DIFFERENT QUESTIONS, and a
|
|
60
|
+
single `find()` has to pick one answer for both. Vast picked capacity — it filters
|
|
61
|
+
`actual_status == "exited"` out — and on 2026-09-24 an exited-but-contracted instance was
|
|
62
|
+
therefore invisible to the pool census AND to the teardown at the same time, while the
|
|
63
|
+
account was billed for it. So the questions are asked separately now, and a provider that
|
|
64
|
+
genuinely cannot tell them apart says so by not implementing the split.
|
|
65
|
+
|
|
66
|
+
AND SAYS SO OUT LOUD, not merely by omission (owner ruling, 2026-09-24, second half). Not
|
|
67
|
+
implementing the split makes the fallback silent: every caller then gets the one answer the
|
|
68
|
+
provider has, dressed as the answer it asked for. `DISTINGUISHES_SERVING` is that omission
|
|
69
|
+
written down, so `stop()`, the placement confirmation and the pool census can REPORT the
|
|
70
|
+
ambiguity instead of resolving it toward "clean".
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
name: str
|
|
74
|
+
|
|
75
|
+
#: Can this provider separate "an instance is PRESENT" from "an instance is SERVING"?
|
|
76
|
+
#:
|
|
77
|
+
#: DECLARED, never inferred from behaviour. It is a statement about the provider's own status
|
|
78
|
+
#: vocabulary — which only the author of the adapter can make — and the default is True
|
|
79
|
+
#: because every provider that implements `find_live`/`find_all` has made it. A provider that
|
|
80
|
+
#: falls back to `find` for both MUST set this False; `test_gpuproviders.py` fails any
|
|
81
|
+
#: registered provider whose declaration and whose methods disagree, so the optimistic
|
|
82
|
+
#: default cannot be inherited by accident.
|
|
83
|
+
DISTINGUISHES_SERVING: bool = True
|
|
84
|
+
|
|
85
|
+
def configured(self) -> bool:
|
|
86
|
+
"""Is there a credential for this provider? Never raises."""
|
|
87
|
+
|
|
88
|
+
async def find(self, label: str) -> List[Instance]:
|
|
89
|
+
"""Live (non-terminated) instances carrying this label.
|
|
90
|
+
|
|
91
|
+
THE BASE PRIMITIVE, and deliberately NOT the one a caller with a question should reach
|
|
92
|
+
for: see `find_live` / `find_all` below. It is kept because `start`'s idempotency check
|
|
93
|
+
wants exactly this — "is anything of mine already here", answered as conservatively as
|
|
94
|
+
each provider can answer it.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
async def find_live(self, label: str) -> List[Instance]:
|
|
98
|
+
"""OPTIONAL. CAPACITY: instances carrying this label that are actually SERVING.
|
|
99
|
+
|
|
100
|
+
Optional because not every provider can tell. One asked through the module-level
|
|
101
|
+
`find_live()` helper, which falls back to `list_live` and then to `find`.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
async def find_all(self, label: str) -> List[Instance]:
|
|
105
|
+
"""OPTIONAL. TEARDOWN: every instance carrying this label that could still BILL.
|
|
106
|
+
|
|
107
|
+
Optional, same as `find_live`, and asked through the module-level `find_all()` helper.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
async def create(self, label: str, spec: dict) -> bool:
|
|
111
|
+
"""Create ONE instance carrying THIS label.
|
|
112
|
+
|
|
113
|
+
The label argument wins over anything in `spec`. A provider that lets its spec's name
|
|
114
|
+
through is the 2026-08-21 billing bug, reintroduced.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
async def destroy(self, instance_id: str) -> bool:
|
|
118
|
+
"""Terminate it. True when it is gone (or was already)."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---- the two questions, asked separately ---------------------------------------------------
|
|
122
|
+
#
|
|
123
|
+
# THE DEFECT THESE REPLACE (2026-09-24, real money). `find()` answered both "what is serving?"
|
|
124
|
+
# and "what is still billing?", so each provider's author had to choose which one to be wrong
|
|
125
|
+
# about. Vast chose: it drops `exited` instances, which is RIGHT for capacity — an exited pod
|
|
126
|
+
# must not hold the pool's only slot and block its own replacement — and WRONG for teardown,
|
|
127
|
+
# where `nodewake._provider_has`'s own docstring already required the opposite ("a
|
|
128
|
+
# RUNNING-but-runtimeless pod serves nothing and bills anyway, and must still be found").
|
|
129
|
+
# A Vast instance that took the contract and then exited was consequently invisible to BOTH
|
|
130
|
+
# readers at once: `/control/pool` said 0 and the teardown said `terminated: []`, while the
|
|
131
|
+
# account was charged.
|
|
132
|
+
#
|
|
133
|
+
# Neither helper invents an answer a provider cannot give. A provider that cannot distinguish
|
|
134
|
+
# the two states falls back to the one enumeration it has, which is no worse than before — and
|
|
135
|
+
# `test_gpuproviders.py` pins, per provider, which of them can.
|
|
136
|
+
#
|
|
137
|
+
# THE FALLBACK IS NOT FREE, AND IT IS NO LONGER SILENT (owner ruling, 2026-09-24, second half):
|
|
138
|
+
# "where DataCrunch cannot distinguish intent from reality, resolve the ambiguity toward
|
|
139
|
+
# suspected_billing rather than toward clean. It is the EU half of Phase 1, so a provider that
|
|
140
|
+
# cannot answer the teardown question must err on the side of telling you it might be spending."
|
|
141
|
+
# The fallback ANSWER stays what it was — inventing a finer one would mean guessing a status
|
|
142
|
+
# vocabulary, which is the thing the previous pass refused to do and still refuses. What changes
|
|
143
|
+
# is that the answer now travels with `distinguishes_serving()`, and the three places that would
|
|
144
|
+
# otherwise have reported a clean result off it say "presence only" instead.
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def distinguishes_serving(provider: GpuProvider) -> bool:
|
|
148
|
+
"""Can this provider tell a SERVING instance from one that is merely PRESENT?
|
|
149
|
+
|
|
150
|
+
Read from the provider's own declaration (`DISTINGUISHES_SERVING`), defaulting to True. It is
|
|
151
|
+
not inferred from which methods exist, because inference would silently relabel every test
|
|
152
|
+
double and every future adapter the moment it grew or lost a method — and because the claim
|
|
153
|
+
is about a vendor's status vocabulary, which no amount of introspection can see. The
|
|
154
|
+
declaration is kept honest by a contract test over `PROVIDERS`, not by this function.
|
|
155
|
+
|
|
156
|
+
False means: every answer this provider gives about the label is "listed / not listed". A
|
|
157
|
+
caller that reports success on that basis is reporting something it did not verify.
|
|
158
|
+
"""
|
|
159
|
+
return bool(getattr(provider, "DISTINGUISHES_SERVING", True))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def presence_only_note(provider_name: str, label: str, question: str, detail: str,
|
|
163
|
+
terminated: "Optional[List[str]]" = None) -> dict:
|
|
164
|
+
"""The ambiguity, in the shape the money channel already carries.
|
|
165
|
+
|
|
166
|
+
Same keys as `nodewake._abandon_unconfirmed`'s note — provider/name/suspected_billing/
|
|
167
|
+
terminated/detail — ON PURPOSE: `scale_to` already collects those into `unconfirmed` and
|
|
168
|
+
lifts the flagged ones into `suspected_billing`, which is precisely the channel the ruling
|
|
169
|
+
asks this to resolve toward. A second, parallel reporting path would have to be wired into
|
|
170
|
+
every reader again, and the 2026-09-17 orphan bucket is what a recorded-but-unread channel
|
|
171
|
+
is worth.
|
|
172
|
+
"""
|
|
173
|
+
return {
|
|
174
|
+
"provider": provider_name,
|
|
175
|
+
"name": label,
|
|
176
|
+
"suspected_billing": True,
|
|
177
|
+
"presence_only": question,
|
|
178
|
+
"terminated": [str(i) for i in (terminated or [])],
|
|
179
|
+
"detail": detail,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def find_live(provider: GpuProvider, label: str) -> List[Instance]:
|
|
184
|
+
"""CAPACITY: instances under this label that are actually SERVING.
|
|
185
|
+
|
|
186
|
+
The question the pool census and the scale plan ask. A corpse answered "yes" here holds a
|
|
187
|
+
slot forever and no replacement is ever created.
|
|
188
|
+
|
|
189
|
+
WHICH WAY THE FALLBACK ERRS, AND WHY IT IS THE OPPOSITE OF THE REPORTING DIRECTION. For a
|
|
190
|
+
provider that cannot distinguish, "present" is returned as live. Counting it dead instead
|
|
191
|
+
would create a REPLACEMENT while the ambiguous instance is still contracted — two instances,
|
|
192
|
+
two bills, on exactly the evidence that was too weak to classify one. Money is irreversible
|
|
193
|
+
and a short pool is not. `start()` already makes the same choice for the same reason.
|
|
194
|
+
|
|
195
|
+
AND IT IS BOUNDED, WHICH THIS FUNCTION CANNOT DO. "Present counts as live" held forever would
|
|
196
|
+
deadlock a provider that can never tell: DataCrunch would look permanently full and never
|
|
197
|
+
scale. The bound is a clock and therefore belongs with the pool census, not with a stateless
|
|
198
|
+
enumeration — `nodewake._provider_has` holds an ambiguous slot only for the confirmation
|
|
199
|
+
window and then RELEASES it while RECORDING suspected_billing (owner ruling, 2026-09-24).
|
|
200
|
+
Both halves are required: a release without the record is the silent failure this whole line
|
|
201
|
+
of work exists to remove, and a record without the release deadlocks the provider.
|
|
202
|
+
"""
|
|
203
|
+
own = getattr(provider, "find_live", None)
|
|
204
|
+
if callable(own):
|
|
205
|
+
return list(await own(label))
|
|
206
|
+
# `list_live` is RunPod's older name for the same question, and a test fake may define only
|
|
207
|
+
# that. Honoured before `find` so the narrower answer always wins.
|
|
208
|
+
list_live = getattr(provider, "list_live", None)
|
|
209
|
+
if callable(list_live):
|
|
210
|
+
return [Instance(id=p.get("id"), label=p.get("name"))
|
|
211
|
+
for p in await list_live() if str(p.get("name", "")) == label]
|
|
212
|
+
return list(await provider.find(label))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def find_all(provider: GpuProvider, label: str) -> List[Instance]:
|
|
216
|
+
"""TEARDOWN: every instance under this label that could still be BILLING, in any state.
|
|
217
|
+
|
|
218
|
+
The question `stop()` and the disarm sweep ask. A false "no" here is money: it is the
|
|
219
|
+
2026-08-21 fail-to-kill and the 2026-09-24 invisible Vast contract, both.
|
|
220
|
+
"""
|
|
221
|
+
own = getattr(provider, "find_all", None)
|
|
222
|
+
if callable(own):
|
|
223
|
+
return list(await own(label))
|
|
224
|
+
return list(await provider.find(label))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---- the derived lifecycle: written once, shared by every provider -------------------------
|
|
228
|
+
|
|
229
|
+
async def start(provider: GpuProvider, label: str, spec: dict) -> dict:
|
|
230
|
+
"""Idempotent create-on-wake.
|
|
231
|
+
|
|
232
|
+
Both the "already running?" check and the create go through the SAME label, which is what
|
|
233
|
+
makes a second wake a no-op rather than a second bill.
|
|
234
|
+
|
|
235
|
+
DELIBERATELY `find`, not `find_live`: this check exists to stop a SECOND BILL, so the
|
|
236
|
+
conservative answer is the safe one. An instance that exists but is not yet serving (still
|
|
237
|
+
booting) must read as "already here" — treating it as absent is how you buy two. Whether a
|
|
238
|
+
slot COUNTS as capacity is a different question, asked with `find_live` by the pool plan,
|
|
239
|
+
which allocates the replacement a fresh name rather than colliding with the corpse.
|
|
240
|
+
"""
|
|
241
|
+
if not provider.configured():
|
|
242
|
+
return {"ok": False, "error": f"{provider.name} has no credential configured"}
|
|
243
|
+
try:
|
|
244
|
+
existing = await provider.find(label)
|
|
245
|
+
except Exception as e:
|
|
246
|
+
return {"ok": False, "error": f"{provider.name} could not be reached: {type(e).__name__}"}
|
|
247
|
+
if existing:
|
|
248
|
+
return {"ok": True, "already": [i.get("id") for i in existing]}
|
|
249
|
+
try:
|
|
250
|
+
ok = await provider.create(label, spec)
|
|
251
|
+
except Exception as e:
|
|
252
|
+
return {"ok": False, "error": f"{provider.name} create failed: {type(e).__name__}"}
|
|
253
|
+
return {"ok": bool(ok)} if ok else {
|
|
254
|
+
"ok": False,
|
|
255
|
+
"error": f"{provider.name} accepted no instance for '{label}' — usually out of stock or over quota",
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
#: How long the provider-neutral teardown waits for an instance to actually disappear, and how
|
|
260
|
+
#: often it re-asks. RunPod's own `terminate` uses 120s/5s and is the model; this is the same
|
|
261
|
+
#: discipline for every other provider, which had none.
|
|
262
|
+
DESTROY_VERIFY_TIMEOUT_S = float(envvar("DESTROY_VERIFY_TIMEOUT_S", "90"))
|
|
263
|
+
DESTROY_VERIFY_POLL_S = float(envvar("DESTROY_VERIFY_POLL_S", "5"))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def destroy_verified(provider: GpuProvider, instance_id: str,
|
|
267
|
+
label: str) -> "tuple[bool, str]":
|
|
268
|
+
"""Terminate one instance AND CONFIRM IT IS GONE, for ANY provider. Returns (gone, reason).
|
|
269
|
+
|
|
270
|
+
THE DEFECT THIS CLOSES, and it is money. `RunPodProvider.destroy` delegates to its own
|
|
271
|
+
`terminate`, whose docstring states the rule in as many words: *"The API's 2xx is never the
|
|
272
|
+
evidence — REST DELETE returns 2xx without stopping the pod, and it keeps BILLING."* Three of
|
|
273
|
+
the four providers in this file then did exactly that. Vast:
|
|
274
|
+
|
|
275
|
+
r = await c.delete(f"{self._BASE}/instances/{instance_id}/", ...)
|
|
276
|
+
return r.status_code < 300 or r.status_code == 404
|
|
277
|
+
|
|
278
|
+
and DataCrunch and Lambda are the same shape. So `stop()` reported
|
|
279
|
+
``{"ok": True, "terminated": [id]}`` on the strength of an HTTP status, the pool believed the
|
|
280
|
+
slot was free, and nobody looked again. The rule was written down once, enforced for one
|
|
281
|
+
provider, and the other three inherited the wrong half of it.
|
|
282
|
+
|
|
283
|
+
PROVIDER-NEUTRAL BY CONSTRUCTION. It uses only what the `GpuProvider` protocol requires —
|
|
284
|
+
`destroy` to ask and `find_all`/`find` to look — so a provider added tomorrow is covered
|
|
285
|
+
without implementing anything, which is the opposite of how `terminate` ended up RunPod-only.
|
|
286
|
+
`find_all` is the right question here for the same reason `stop` already uses it: teardown
|
|
287
|
+
must see an instance that bills without serving.
|
|
288
|
+
|
|
289
|
+
THE FIRST CHECK IS IMMEDIATE, so the common case — it really is gone — costs one extra API
|
|
290
|
+
call and no waiting, including on RunPod where this is a second opinion on a verification that
|
|
291
|
+
already happened. Re-asking `destroy` each round is deliberate and copied from RunPod's
|
|
292
|
+
terminate, which records that RunPod "sometimes needs it more than once"; a vendor that
|
|
293
|
+
accepted a delete and did nothing is exactly the case being defended against.
|
|
294
|
+
|
|
295
|
+
A TIMEOUT IS NOT A SUCCESS. The return is `(False, reason)`, and `stop` turns that into a
|
|
296
|
+
suspected-billing record rather than into a clean teardown — "I could not confirm" must never
|
|
297
|
+
print like "it is gone".
|
|
298
|
+
"""
|
|
299
|
+
import asyncio as _a
|
|
300
|
+
|
|
301
|
+
# Read from the module, not from parameters. They were keyword-only overrides that only the TESTS
|
|
302
|
+
# ever supplied — "a promise that callers will name it", with no production caller, which is dead
|
|
303
|
+
# code behind a live signature (`test_no_unsupplied_keyword_args`). An operator who needs to move
|
|
304
|
+
# these sets the env vars the constants come from.
|
|
305
|
+
timeout = DESTROY_VERIFY_TIMEOUT_S
|
|
306
|
+
poll = DESTROY_VERIFY_POLL_S
|
|
307
|
+
iid = str(instance_id)
|
|
308
|
+
|
|
309
|
+
async def _still_there() -> "bool | None":
|
|
310
|
+
"""True/False, or None when the question itself could not be asked."""
|
|
311
|
+
try:
|
|
312
|
+
return any(str(i.get("id")) == iid for i in await find_all(provider, label))
|
|
313
|
+
except Exception as e:
|
|
314
|
+
log.warning("%s: could not re-check %s after destroy: %s",
|
|
315
|
+
provider.name, iid, type(e).__name__)
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
asked = await provider.destroy(iid)
|
|
320
|
+
except Exception as e:
|
|
321
|
+
return False, f"destroy raised {type(e).__name__}"
|
|
322
|
+
if not asked:
|
|
323
|
+
return False, "the provider refused the destroy"
|
|
324
|
+
|
|
325
|
+
waited = 0.0
|
|
326
|
+
while True:
|
|
327
|
+
present = await _still_there()
|
|
328
|
+
if present is False:
|
|
329
|
+
return True, "confirmed gone"
|
|
330
|
+
if present is None:
|
|
331
|
+
# NOT counted as gone. A provider we cannot read is a provider we cannot clear, and
|
|
332
|
+
# this is the same distinction `nodewake.TeardownBlocked` draws one layer up.
|
|
333
|
+
return False, "destroy was accepted but the instance could not be re-checked"
|
|
334
|
+
if waited >= timeout:
|
|
335
|
+
log.error("PANIC: %s instance %s still present %.0fs after an accepted destroy — "
|
|
336
|
+
"MAY BE BILLING", provider.name, iid, timeout)
|
|
337
|
+
return False, (f"still present {timeout:.0f}s after the provider accepted the "
|
|
338
|
+
f"destroy — may still be billing")
|
|
339
|
+
await _a.sleep(poll)
|
|
340
|
+
waited += poll
|
|
341
|
+
try:
|
|
342
|
+
await provider.destroy(iid) # RunPod sometimes needs it more than once
|
|
343
|
+
except Exception as e:
|
|
344
|
+
log.warning("%s: re-issued destroy for %s raised %s",
|
|
345
|
+
provider.name, iid, type(e).__name__)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
async def stop(provider: GpuProvider, label: str) -> dict:
|
|
349
|
+
"""Terminate everything carrying this label.
|
|
350
|
+
|
|
351
|
+
Reports WHAT it terminated. "ok with nothing terminated" is a legitimate answer only when
|
|
352
|
+
nothing was running — and the caller can now tell the difference, which they could not
|
|
353
|
+
before.
|
|
354
|
+
|
|
355
|
+
A PROVIDER THAT CANNOT ANSWER THE TEARDOWN QUESTION DOES NOT GET TO REPORT A CLEAN TEARDOWN
|
|
356
|
+
(owner ruling, 2026-09-24). Where `distinguishes_serving()` is False, every id this reached
|
|
357
|
+
was chosen by PRESENCE, and "nothing else of mine is billing there" was never verified —
|
|
358
|
+
it was one enumeration answering a question it cannot answer. `ok` stays honest about the
|
|
359
|
+
terminates that did happen (flipping it would push a real teardown into `failed` and hide
|
|
360
|
+
the ids), and the ambiguity rides out beside it in `unverified`, flagged suspected_billing.
|
|
361
|
+
"""
|
|
362
|
+
if not provider.configured():
|
|
363
|
+
return {"ok": False, "error": f"{provider.name} has no credential configured"}
|
|
364
|
+
try:
|
|
365
|
+
# `find_all`, NOT `find`: teardown must reach an instance that bills without serving.
|
|
366
|
+
# Asking the capacity question here is what let an exited-but-contracted Vast instance
|
|
367
|
+
# survive a teardown that reported success (2026-09-24).
|
|
368
|
+
live = await find_all(provider, label)
|
|
369
|
+
except Exception as e:
|
|
370
|
+
return {"ok": False, "error": f"{provider.name} could not be reached: {type(e).__name__}"}
|
|
371
|
+
terminated, failed, unconfirmed = [], [], []
|
|
372
|
+
for inst in live:
|
|
373
|
+
iid = str(inst.get("id"))
|
|
374
|
+
# VERIFIED, for every provider. This used to be `await provider.destroy(iid)` and the
|
|
375
|
+
# boolean was the whole evidence — which for Vast, DataCrunch and Lambda meant an HTTP
|
|
376
|
+
# status, since their `destroy` returns `status < 300 or 404`. RunPod was the only one
|
|
377
|
+
# whose `destroy` confirmed anything, because it delegates to its own verifying
|
|
378
|
+
# `terminate`. See `destroy_verified`.
|
|
379
|
+
gone, why = await destroy_verified(provider, iid, label)
|
|
380
|
+
if gone:
|
|
381
|
+
terminated.append(iid)
|
|
382
|
+
elif why == "the provider refused the destroy" or why.startswith("destroy raised"):
|
|
383
|
+
# It never got started. That is a plain failure and already reported as one.
|
|
384
|
+
failed.append(iid)
|
|
385
|
+
else:
|
|
386
|
+
# ACCEPTED AND NOT CONFIRMED GONE: the dangerous case, and the one that used to read
|
|
387
|
+
# as success. It goes to the money channel, not to `terminated`.
|
|
388
|
+
unconfirmed.append({"id": iid, "reason": why})
|
|
389
|
+
res = {"ok": not failed and not unconfirmed, "terminated": terminated,
|
|
390
|
+
**({"failed": failed} if failed else {})}
|
|
391
|
+
if unconfirmed:
|
|
392
|
+
# `ok` is False here, unlike the presence-only note below. The difference is real: there,
|
|
393
|
+
# the instance IS gone as far as anything can tell and only the "nothing ELSE is billing"
|
|
394
|
+
# claim is weak. Here a specific instance was still listed after its own destroy was
|
|
395
|
+
# accepted, so there is a named thing that may be spending money right now.
|
|
396
|
+
res["unconfirmed_destroys"] = unconfirmed
|
|
397
|
+
res["suspected_billing"] = True
|
|
398
|
+
ids = [u["id"] for u in unconfirmed]
|
|
399
|
+
log.error("%s: %d instance(s) under %r accepted a destroy and are STILL LISTED: %s — "
|
|
400
|
+
"treating the teardown as NOT clean", provider.name, len(ids), label, ids)
|
|
401
|
+
_SUSPECTED.extend(
|
|
402
|
+
presence_only_note(
|
|
403
|
+
provider.name, label, "destroy-unconfirmed",
|
|
404
|
+
f"instance {u['id']} {u['reason']}. The provider accepted the destroy, so a "
|
|
405
|
+
f"caller trusting that answer would have recorded a clean teardown. Check by "
|
|
406
|
+
f"hand: python -m frutebox.gpuproviders stop-label {label} {provider.name}",
|
|
407
|
+
[u["id"]])
|
|
408
|
+
for u in unconfirmed)
|
|
409
|
+
if not distinguishes_serving(provider):
|
|
410
|
+
res["unverified"] = presence_only_note(
|
|
411
|
+
provider.name, label, "teardown",
|
|
412
|
+
(f"{provider.name} cannot tell an instance that is SERVING from one that is merely "
|
|
413
|
+
f"PRESENT — it has a single enumeration and answers both questions with it. This "
|
|
414
|
+
f"teardown terminated {len(terminated)} instance(s) under {label!r} on presence "
|
|
415
|
+
f"alone, so 'nothing is billing under that label on {provider.name} any more' is "
|
|
416
|
+
f"NOT a verified statement: it is the only answer that provider can give. Treat it "
|
|
417
|
+
f"as possibly still spending until checked by hand — python -m "
|
|
418
|
+
f"frutebox.gpuproviders stop-label {label} {provider.name}"),
|
|
419
|
+
terminated)
|
|
420
|
+
return res
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# ---- RunPod --------------------------------------------------------------------------------
|
|
424
|
+
#
|
|
425
|
+
# THE SINGLE DOOR (KB §0). Every RunPod touch in this codebase goes through this class — the SDK
|
|
426
|
+
# import, the proxy hostname, the GraphQL endpoint for prices, all of it lives HERE and nowhere
|
|
427
|
+
# else, enforced by tests/test_gpu_single_door.py. Almost every RunPod incident we have had
|
|
428
|
+
# traces to the same root cause: many call sites, each implemented ad hoc, so a fix in one path
|
|
429
|
+
# never reached the others. A new GPU type or a different start is a `PodSpec` value handed to the
|
|
430
|
+
# same proven code — never a new function that re-implements create/wait/terminate.
|
|
431
|
+
|
|
432
|
+
from dataclasses import dataclass, field
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@dataclass(frozen=True)
|
|
436
|
+
class PodSpec:
|
|
437
|
+
"""Everything a bring-up needs, as VALUES. New hardware = a new PodSpec, never new code."""
|
|
438
|
+
name: str
|
|
439
|
+
image: str
|
|
440
|
+
serving_port: int = 11434
|
|
441
|
+
#: (gpu_type_id, cloud_type) or (gpu_type_id, cloud_type, data_center_id), tried in order.
|
|
442
|
+
#: SECURE first: Community publishes no uptime SLA and is where "placed but never scheduled"
|
|
443
|
+
#: clusters — but it stays in the list, because an available Community pod beats no pod.
|
|
444
|
+
#: NOTE: when `network_volume_id` is set, the VOLUME's datacenter overrides any entry's — a
|
|
445
|
+
#: RunPod network volume is datacenter-locked, and deploying elsewhere cannot mount it.
|
|
446
|
+
gpu_fallbacks: tuple = (
|
|
447
|
+
("NVIDIA RTX A6000", "SECURE"),
|
|
448
|
+
("NVIDIA RTX A5000", "SECURE"),
|
|
449
|
+
("NVIDIA A40", "SECURE"),
|
|
450
|
+
("NVIDIA RTX A6000", "COMMUNITY"),
|
|
451
|
+
)
|
|
452
|
+
#: GPUs IN ONE POD. `create_pod` hardcoded 1, and that made the tier-S placement the whole
|
|
453
|
+
#: unit-economics story rests on unreachable through this door: file 01-B2's default is
|
|
454
|
+
#: Qwen3-235B-A22B FP8 on **2x H200** (~$7.18/hr Community) and `serving.gpu_plan` returns
|
|
455
|
+
#: exactly that multi-GPU config — which this module could then only ask RunPod for one card
|
|
456
|
+
#: of. A 235 GB model on one 141 GB H200 boots, bills, and loads forever: the 2026-08-30 root
|
|
457
|
+
#: cause with a different card. Tensor parallelism also needs the count to be a power of two,
|
|
458
|
+
#: which `serving_spec` already enforces when it builds the plan.
|
|
459
|
+
gpu_count: int = 1
|
|
460
|
+
container_disk_gb: int = 40
|
|
461
|
+
volume_gb: int = 0
|
|
462
|
+
network_volume_id: str = ""
|
|
463
|
+
#: A volume implies its datacenter; naming it makes the pin explicit in the request so a
|
|
464
|
+
#: mismatch fails loudly instead of the API inferring something else.
|
|
465
|
+
data_center_id: str = ""
|
|
466
|
+
docker_args: str = ""
|
|
467
|
+
#: RunPod's id for a stored container-registry credential. Our node image is PRIVATE, so a pod
|
|
468
|
+
#: cannot pull it without one — a pod created without this is rented and exits in ~2 seconds
|
|
469
|
+
#: with `runtime: null`, which is what happened on 2026-09-20 before this existed.
|
|
470
|
+
#: `runpod.create_pod` has no parameter for it (measured against 1.12.0, the latest release),
|
|
471
|
+
#: so it travels via a template — see `_template_id_for`.
|
|
472
|
+
container_registry_auth_id: str = ""
|
|
473
|
+
#: WHICH PORTS RUNPOD EXPOSES, as RunPod's own "8081/http,11434/http" string. Distinct from
|
|
474
|
+
#: `serving_port`, which is the ONE port the control plane dials for readiness (`serves()`);
|
|
475
|
+
#: this is everything the pod should reach the outside on.
|
|
476
|
+
#:
|
|
477
|
+
#: It exists because the operator's `ports` was being thrown away. `from_operator_spec` never
|
|
478
|
+
#: read it and every outgoing call rebuilt `f"{serving_port}/http"`, so `_WARM`'s declared
|
|
479
|
+
#: "8080/http,8081/http" had been silently reduced to "11434/http" for as long as it has been
|
|
480
|
+
#: set. The cost was not abstract: the node image runs a boot-status sidecar on :8081 built
|
|
481
|
+
#: precisely so a slow model pull and a dead container are distinguishable, and on 2026-09-21
|
|
482
|
+
#: a pod ran 15 minutes without registering and the sidecar could not be reached to say why.
|
|
483
|
+
ports: str = ""
|
|
484
|
+
#: THE PATH the readiness probe asks for on `serving_port`. Default `/` because that is what
|
|
485
|
+
#: Ollama and llama.cpp answer (both have a root page) and this door was built for them.
|
|
486
|
+
#:
|
|
487
|
+
#: IT IS A FIELD BECAUSE SGLang HAS NO `/` ROUTE. Its FastAPI app serves `/health`,
|
|
488
|
+
#: `/get_model_info`, `/v1/models` and the completion endpoints — a probe on `/` 404s forever,
|
|
489
|
+
#: which is a readiness predicate that CANNOT PASS: the pod boots, serves, and is reaped at the
|
|
490
|
+
#: timeout as dead. That is the 2026-08-30 incident in this KB (a predicate reading a field the
|
|
491
|
+
#: query never returned) with a different cause and the same cost, and the tier-S recipe would
|
|
492
|
+
#: have hit it on its first armed run.
|
|
493
|
+
health_path: str = "/"
|
|
494
|
+
env: dict = field(default_factory=dict)
|
|
495
|
+
#: ONE NAMED VARIANT = ONE TEMPLATE OF ITS OWN, carrying its per-pod env.
|
|
496
|
+
#:
|
|
497
|
+
#: A POOL spec deploys N interchangeable slots that differ only by `STEVAI_NODE_ID`, so its
|
|
498
|
+
#: template must be SHARED or every slot mints one (`_PER_POD_ENV`, below). A VARIANT is one
|
|
499
|
+
#: deliberate pod — `frutebox-media` — whose identity and advertised modalities ARE the spec.
|
|
500
|
+
#: Stripping its per-pod env to share a template with nothing is how the media node booted as
|
|
501
|
+
#: `runpod-node-1` and advertised text only: the values that make it a media node never
|
|
502
|
+
#: reached the container at all.
|
|
503
|
+
#:
|
|
504
|
+
#: Bounded by construction: one template per configured variant, not one per pod or per
|
|
505
|
+
#: attempt. The name is a pure function of the full env, so a re-arm of the same variant
|
|
506
|
+
#: FINDS its template instead of minting another.
|
|
507
|
+
dedicated_template: bool = False
|
|
508
|
+
#: Sized to the documented COLD START (image + model pull), not the serving SLA — confusing
|
|
509
|
+
#: the two once reaped six healthy pods mid-boot.
|
|
510
|
+
ready_timeout_s: int = int(envvar("POD_READY_TIMEOUT_S", "780"))
|
|
511
|
+
poll_s: int = int(envvar("POD_READY_POLL_S", "10"))
|
|
512
|
+
|
|
513
|
+
@staticmethod
|
|
514
|
+
def from_operator_spec(spec: dict, name: str = "") -> "PodSpec":
|
|
515
|
+
"""Adapt a RUNPOD_POD_SPEC-shaped dict (the operator's config format) into a PodSpec."""
|
|
516
|
+
env = spec.get("env", {})
|
|
517
|
+
# A spec that PINS GPUs (gpuTypeId/gpuTypeIds — e.g. the frontier variant pinning H100)
|
|
518
|
+
# must drive the fallback walk with those pins, not with the default tier-A list: the
|
|
519
|
+
# default would happily deploy an A6000 where the operator specified frontier hardware.
|
|
520
|
+
pins = [g for g in ([spec.get("gpuTypeId")] + list(spec.get("gpuTypeIds") or [])) if g]
|
|
521
|
+
cloud = str(spec.get("cloudType", "") or "").upper()
|
|
522
|
+
clouds = [cloud] if cloud in ("SECURE", "COMMUNITY") else ["SECURE", "COMMUNITY"]
|
|
523
|
+
pinned_fallbacks = tuple((g, c) for c in clouds for g in pins)
|
|
524
|
+
return PodSpec(
|
|
525
|
+
name=name or str(spec.get("name") or "frutebox-runpod-1"),
|
|
526
|
+
**({"gpu_fallbacks": pinned_fallbacks} if pinned_fallbacks else {}),
|
|
527
|
+
image=str(spec.get("imageName") or spec.get("image") or "ollama/ollama:latest"),
|
|
528
|
+
serving_port=int(envvar("SERVING_PORT", "11434")),
|
|
529
|
+
# The operator's spec has carried `gpuCount` since the frontier example was
|
|
530
|
+
# written (it asks for 8x H100); it was read by nothing.
|
|
531
|
+
gpu_count=max(1, int(spec.get("gpuCount", 1) or 1)),
|
|
532
|
+
container_disk_gb=int(spec.get("containerDiskInGb", 40) or 40),
|
|
533
|
+
volume_gb=int(spec.get("volumeInGb", 0) or 0),
|
|
534
|
+
# ENV FIRST, spec second: where money is spent is an OPERATOR decision, not a
|
|
535
|
+
# per-spec preference — the operator's configured volume overrides whatever a spec
|
|
536
|
+
# happens to carry.
|
|
537
|
+
network_volume_id=str(os.environ.get("RUNPOD_NETWORK_VOLUME_ID", "").strip()
|
|
538
|
+
or spec.get("networkVolumeId") or ""),
|
|
539
|
+
data_center_id=str(os.environ.get("RUNPOD_VOLUME_DATACENTER", "").strip()
|
|
540
|
+
or spec.get("dataCenterId") or ""),
|
|
541
|
+
docker_args=str(spec.get("dockerArgs", "") or ""),
|
|
542
|
+
container_registry_auth_id=str(spec.get("containerRegistryAuthId", "") or ""),
|
|
543
|
+
dedicated_template=bool(spec.get("dedicatedTemplate", False)),
|
|
544
|
+
ports=str(spec.get("ports", "") or ""),
|
|
545
|
+
env=env if isinstance(env, dict) else {},
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def ports_string(spec: PodSpec) -> str:
|
|
550
|
+
"""What RunPod should expose, as its "port/http,port/http" string. ONE definition, because
|
|
551
|
+
three calls need the identical answer.
|
|
552
|
+
|
|
553
|
+
The template's ports and the pod's ports must agree, and the template NAME is a hash of the
|
|
554
|
+
template's ports — so a helper that drifted from its callers would mint a template per call
|
|
555
|
+
and then hand the pod a different set than the one it was named for.
|
|
556
|
+
|
|
557
|
+
THE SERVING PORT IS ALWAYS PRESENT, even when the operator's string omits it. The control
|
|
558
|
+
plane dials `serving_port` to decide a pod is ready (`serves()`); a spec that left it out
|
|
559
|
+
would produce a pod that boots correctly, cannot be reached, and is reaped as dead — paid for
|
|
560
|
+
and useless. Adding it back is cheaper than that, and saying so in the log means the spec
|
|
561
|
+
still gets fixed rather than the mistake staying invisible.
|
|
562
|
+
"""
|
|
563
|
+
want = f"{spec.serving_port}/http"
|
|
564
|
+
declared = [p.strip() for p in (spec.ports or "").split(",") if p.strip()]
|
|
565
|
+
if not declared:
|
|
566
|
+
return want
|
|
567
|
+
if not any(p.split("/", 1)[0] == str(spec.serving_port) for p in declared):
|
|
568
|
+
log.warning("spec %s declares ports %r but not the serving port %s that readiness dials "
|
|
569
|
+
"— adding it; fix the spec", spec.name, spec.ports, spec.serving_port)
|
|
570
|
+
declared.append(want)
|
|
571
|
+
return ",".join(declared)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
#: Usable VRAM per GPU type, GB. Conservative round-downs of the marketing numbers. Used by the
|
|
575
|
+
#: model-fit assertion below; a type not listed is treated as unknown and only the disk check runs.
|
|
576
|
+
GPU_VRAM_GB = {
|
|
577
|
+
"NVIDIA A40": 48, "NVIDIA RTX A6000": 48, "NVIDIA RTX 6000 Ada Generation": 48,
|
|
578
|
+
"NVIDIA RTX A5000": 24, "NVIDIA RTX A4000": 16,
|
|
579
|
+
"NVIDIA H100 80GB HBM3": 80, "NVIDIA H100 PCIe": 80, "NVIDIA H200": 141,
|
|
580
|
+
"NVIDIA A100 80GB PCIe": 80, "NVIDIA A100-SXM4-80GB": 80,
|
|
581
|
+
"NVIDIA L40S": 48, "NVIDIA GeForce RTX 4090": 24,
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
#: Does the card have NATIVE FP8 tensor cores? Ampere and older do not; Ada, Hopper and Blackwell
|
|
585
|
+
#: do. This is an ARCHITECTURE fact, not a second opinion about our inventory — `serving.py`
|
|
586
|
+
#: carries the same flag for the subset of cards it prices, and
|
|
587
|
+
#: `test_the_fp8_table_agrees_with_serving_and_covers_every_card` fails if the two ever disagree
|
|
588
|
+
#: or if a card is added to one table and not the other. That test is the point: a hand-kept list
|
|
589
|
+
#: is correct the day it is written and quietly wrong afterwards, which is how the single-door
|
|
590
|
+
#: guard came to cover one vendor.
|
|
591
|
+
GPU_FP8 = {
|
|
592
|
+
"NVIDIA A40": False, "NVIDIA RTX A6000": False,
|
|
593
|
+
"NVIDIA RTX A5000": False, "NVIDIA RTX A4000": False,
|
|
594
|
+
"NVIDIA A100 80GB PCIe": False, "NVIDIA A100-SXM4-80GB": False,
|
|
595
|
+
"NVIDIA H100 80GB HBM3": True, "NVIDIA H100 PCIe": True, "NVIDIA H200": True,
|
|
596
|
+
"NVIDIA RTX 6000 Ada Generation": True, "NVIDIA L40S": True,
|
|
597
|
+
"NVIDIA GeForce RTX 4090": True,
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
#: serving.py's inventory name -> RunPod's `gpuTypeId`. THE MISSING HALF OF A COSTED PLACEMENT.
|
|
602
|
+
#:
|
|
603
|
+
#: `serving.gpu_plan()` answers "which hardware fits this model, cheapest first" in its own
|
|
604
|
+
#: vocabulary ("H200 SXM"), and this module asks RunPod for hardware in RunPod's ("NVIDIA H200").
|
|
605
|
+
#: Nothing translated between them, so a cost-ranked plan could be computed and never requested —
|
|
606
|
+
#: which is one reason the tier-S recipe had no launcher at all. The translation belongs here
|
|
607
|
+
#: because RunPod's id vocabulary is this door's business and nothing else's (KB §0).
|
|
608
|
+
#:
|
|
609
|
+
#: An empty value means DELIBERATELY UNMAPPED: the card is priced by `serving.py` but this door has
|
|
610
|
+
#: no id it can vouch for, so a placement on it is skipped rather than guessed. A guessed id is not
|
|
611
|
+
#: a near miss — RunPod refuses it, and the refusal reads exactly like a capacity shortage.
|
|
612
|
+
RUNPOD_GPU_TYPE_ID = {
|
|
613
|
+
"RTX A4000": "NVIDIA RTX A4000",
|
|
614
|
+
"RTX A5000": "NVIDIA RTX A5000",
|
|
615
|
+
"A40": "NVIDIA A40",
|
|
616
|
+
"RTX A6000": "NVIDIA RTX A6000",
|
|
617
|
+
"A100 PCIe": "NVIDIA A100 80GB PCIe",
|
|
618
|
+
"A100 SXM": "NVIDIA A100-SXM4-80GB",
|
|
619
|
+
"H100 SXM": "NVIDIA H100 80GB HBM3",
|
|
620
|
+
"H200 SXM": "NVIDIA H200",
|
|
621
|
+
# RunPod's own id for the Blackwell workstation card is not one we have seen in a reply from
|
|
622
|
+
# the account, and `GPU_VRAM_GB`/`GPU_FP8` do not carry it either. Unmapped on purpose.
|
|
623
|
+
"RTX PRO 6000": "",
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def runpod_gpu_type_id(serving_gpu_type: str) -> str:
|
|
628
|
+
"""RunPod's id for a `serving.GpuConfig.gpu_type`, or "" when this door cannot vouch for one.
|
|
629
|
+
|
|
630
|
+
KeyError would be the wrong answer for an unknown name: the caller is walking a cost-ranked
|
|
631
|
+
list and should skip a card it cannot ask for, not abort a bring-up over the cheapest entry.
|
|
632
|
+
"""
|
|
633
|
+
return RUNPOD_GPU_TYPE_ID.get(serving_gpu_type, "")
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def model_fit_error(model: str, gpu_type_id: str, gpu_count: int, disk_gb: int) -> str:
|
|
637
|
+
"""PURE. Why this model cannot run on this hardware — or "" when it fits.
|
|
638
|
+
|
|
639
|
+
THE ROOT CAUSE THIS ENCODES (owner-diagnosed from the console System Logs, 2026-08-30): the
|
|
640
|
+
default pool spec carried the tier-S Kimi config, so every A40 boot tried to download and load
|
|
641
|
+
a ~595 GB model onto 48 GB of VRAM and a small disk. llama-server's /health said "loading"
|
|
642
|
+
forever, the boot script looped for two hours, the node never registered, and the pod billed
|
|
643
|
+
while "running". Ten-plus pods died that way across three days, and it looked exactly like a
|
|
644
|
+
provider scheduling fault. A doomed combination must be refused BEFORE money is spent —
|
|
645
|
+
clearly, naming the numbers.
|
|
646
|
+
"""
|
|
647
|
+
try:
|
|
648
|
+
from . import serving
|
|
649
|
+
ms = serving.serving_spec(model)
|
|
650
|
+
except Exception:
|
|
651
|
+
return "" # unknown model: nothing to assert against
|
|
652
|
+
need_vram = float(getattr(ms, "vram_gb", 0) or 0)
|
|
653
|
+
if not need_vram:
|
|
654
|
+
return ""
|
|
655
|
+
# The download lands on the container disk first (plus image + headroom).
|
|
656
|
+
need_disk = need_vram * 1.1 + 12
|
|
657
|
+
if disk_gb and need_disk > disk_gb:
|
|
658
|
+
return (f"model {model!r} needs ~{need_vram:.0f}GB downloaded (≥{need_disk:.0f}GB disk with "
|
|
659
|
+
f"headroom) but the spec gives {disk_gb}GB — this pod would bill while failing")
|
|
660
|
+
per_gpu = GPU_VRAM_GB.get(gpu_type_id)
|
|
661
|
+
if per_gpu is not None and need_vram > per_gpu * max(1, gpu_count):
|
|
662
|
+
return (f"model {model!r} needs ~{need_vram:.0f}GB VRAM but {gpu_count}x {gpu_type_id} "
|
|
663
|
+
f"has {per_gpu * max(1, gpu_count)}GB — this pod would bill while failing")
|
|
664
|
+
# ENOUGH VRAM IS NOT ENOUGH. An FP8 model on an Ampere card has the memory and still cannot
|
|
665
|
+
# load: 6x A40 is 288GB, more than the 235GB the weights need, and the pod would bill while
|
|
666
|
+
# the server failed to start. `serving.gpu_plan(require_fp8=True)` already refuses the
|
|
667
|
+
# placement when the plan is BUILT; this is the check on the path that refuses a doomed
|
|
668
|
+
# combination someone SUPPLIED, which is where a hand-written variant spec arrives.
|
|
669
|
+
# `engine == "sglang"` is the FP8 path — the only branch `serving_spec` sends there.
|
|
670
|
+
if getattr(ms, "engine", "") == "sglang" and GPU_FP8.get(gpu_type_id) is False:
|
|
671
|
+
return (f"model {model!r} is served FP8 and {gpu_type_id} has no native FP8 tensor cores "
|
|
672
|
+
f"(Ampere or older) — {gpu_count}x gives {per_gpu * max(1, gpu_count) if per_gpu else '?'}GB, "
|
|
673
|
+
f"enough memory and still unable to load; this pod would bill while failing")
|
|
674
|
+
return ""
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
# ---- THE REAPER ----------------------------------------------------------------------------
|
|
679
|
+
#
|
|
680
|
+
# THE RULE THIS ENCODES (2026-09-17, after a 13h20m orphan billed $6.54):
|
|
681
|
+
#
|
|
682
|
+
# A TIMEOUT ON A CALL THAT CREATES A RESOURCE IS NOT EVIDENCE THAT NOTHING WAS CREATED.
|
|
683
|
+
# It is evidence that you do not know. Treat it as "may have succeeded" and reconcile
|
|
684
|
+
# against the provider's own list before moving on.
|
|
685
|
+
#
|
|
686
|
+
# What happened: `create_pod` wrapped the SDK call in `wait_for(..., timeout=180)`. The call
|
|
687
|
+
# timed out, the `except` logged "create REFUSED" and returned None, and the caller moved to the
|
|
688
|
+
# next fallback. But `wait_for` cannot cancel a blocking call running in a worker thread — the
|
|
689
|
+
# request was still in flight, and RunPod created the pod minutes later, after the process had
|
|
690
|
+
# exited. Nothing owned it, so nothing reaped it. It billed overnight with `runtime: None`.
|
|
691
|
+
#
|
|
692
|
+
# Two properties make this reaper different from the teardown that already existed:
|
|
693
|
+
#
|
|
694
|
+
# 1. The reap set comes from RUNPOD'S LIST, never from the ids we think we created. An orphan
|
|
695
|
+
# we never saw an id for is exactly the case that costs money, and it is invisible to any
|
|
696
|
+
# bookkeeping built on our own return values.
|
|
697
|
+
# 2. It runs in a `finally`, so an exception, a timeout, or a KeyboardInterrupt on the wake
|
|
698
|
+
# path cannot skip it.
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
#: Reconcile timings. Module-level so tests drive the real code paths at speed rather than
|
|
702
|
+
#: mocking them out — the 2026-09-17 bug lived in a path that had never once been executed.
|
|
703
|
+
_AMBIGUOUS_WINDOW_S = 120.0 # how long to watch for a pod after an ambiguous create
|
|
704
|
+
_AMBIGUOUS_POLL_S = 10.0
|
|
705
|
+
_SETTLE_POLL_S = 10.0 # re-list interval while waiting for late pods to settle
|
|
706
|
+
_SETTLE_S = 180.0 # how long an ambiguous create keeps the exit reap watching
|
|
707
|
+
#: An unowned pod older than this is no longer "someone else's pod, probably fine" — it is a
|
|
708
|
+
#: suspected orphan and must escalate past a log line. 30 min is well past any legitimate
|
|
709
|
+
#: bring-up (the documented cold start is ~94 min, but a DIAG pod is watched by a human from the
|
|
710
|
+
#: first minute); the 2026-09-17 orphan sat for 13h20m inside a "reported" bucket.
|
|
711
|
+
_ORPHAN_SUSPECT_AGE_H = 0.5
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
class PodTracker:
|
|
715
|
+
"""The set of pods a guarded window is allowed to leave running.
|
|
716
|
+
|
|
717
|
+
`saw()` is bookkeeping for the log. `keep()` is the only thing that spares a pod: a pod is
|
|
718
|
+
reaped unless the caller explicitly confirmed it wants it alive. Silence never means keep.
|
|
719
|
+
"""
|
|
720
|
+
|
|
721
|
+
def __init__(self, owns: str):
|
|
722
|
+
self.owns = owns
|
|
723
|
+
self.seen: Set[str] = set()
|
|
724
|
+
self.kept: Set[str] = set()
|
|
725
|
+
#: names whose create call ended ambiguously (timeout/transport error). Their pods may
|
|
726
|
+
#: still materialise AFTER the call returned, so the exit reap waits for them to settle.
|
|
727
|
+
self.ambiguous: Set[str] = set()
|
|
728
|
+
|
|
729
|
+
def saw(self, pod_id: str) -> None:
|
|
730
|
+
if pod_id:
|
|
731
|
+
self.seen.add(pod_id)
|
|
732
|
+
|
|
733
|
+
def keep(self, pod_id: str) -> None:
|
|
734
|
+
"""Confirm this pod is intentionally alive — the caller owns it from here."""
|
|
735
|
+
if pod_id:
|
|
736
|
+
self.kept.add(pod_id)
|
|
737
|
+
self.seen.add(pod_id)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
#: Suspected orphans seen by any guard in this process. A wake that runs after one was found can
|
|
741
|
+
#: read this and refuse to add more pods to an account nobody has cleaned up.
|
|
742
|
+
_SUSPECTED: "List[dict]" = []
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def suspected_orphans() -> "List[dict]":
|
|
746
|
+
"""Unowned pods past the suspect age that a guard has seen in this process. Not empty means
|
|
747
|
+
somebody must look at the account before more pods are armed."""
|
|
748
|
+
return list(_SUSPECTED)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
#: The guard active on this task, so `create_pod` can register what it learns without every
|
|
752
|
+
#: call site having to thread a tracker through. None = unguarded (the reaper then warns).
|
|
753
|
+
_TRACKER: "contextvars.ContextVar[Optional[PodTracker]]" = contextvars.ContextVar(
|
|
754
|
+
"runpod_tracker", default=None)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _pod_age_hours(pod: dict) -> "float | None":
|
|
758
|
+
"""Best effort age in hours, or None when RunPod gave us no timestamp.
|
|
759
|
+
|
|
760
|
+
Deliberately tolerant: a missing or unparseable timestamp must never stop a reap. Age is for
|
|
761
|
+
the LOG — so an orphan leaves a trace saying how long it had been burning — not for the
|
|
762
|
+
decision about whether to kill it.
|
|
763
|
+
"""
|
|
764
|
+
import datetime as _dt
|
|
765
|
+
for key in ("createdAt", "created_at", "lastStatusChange", "podCreatedAt"):
|
|
766
|
+
raw = pod.get(key)
|
|
767
|
+
if not raw:
|
|
768
|
+
continue
|
|
769
|
+
text = str(raw)
|
|
770
|
+
# "Rented by user: Mon Sep 15 2026 17:05:17 GMT+0000" and friends.
|
|
771
|
+
if ":" in text and not text[:4].isdigit():
|
|
772
|
+
text = text.split(":", 1)[1].strip()
|
|
773
|
+
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S",
|
|
774
|
+
"%a %b %d %Y %H:%M:%S GMT%z", "%a %b %d %Y %H:%M:%S"):
|
|
775
|
+
try:
|
|
776
|
+
when = _dt.datetime.strptime(text[:len(_dt.datetime.now().strftime(fmt)) + 8]
|
|
777
|
+
if "%z" not in fmt else text, fmt)
|
|
778
|
+
except Exception:
|
|
779
|
+
continue
|
|
780
|
+
if when.tzinfo is None:
|
|
781
|
+
when = when.replace(tzinfo=_dt.timezone.utc)
|
|
782
|
+
return (_dt.datetime.now(_dt.timezone.utc) - when).total_seconds() / 3600.0
|
|
783
|
+
return None
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _pool_scope(name: str) -> str:
|
|
787
|
+
"""The ownership prefix for a pod name — 'frutebox-runpod-1' owns 'frutebox-runpod'.
|
|
788
|
+
|
|
789
|
+
Scoping by the POOL BASE, not the exact name, is deliberate: a create that lands under a
|
|
790
|
+
slightly different name (the 2026-08-21 asymmetry) is still ours and must still be reapable.
|
|
791
|
+
"""
|
|
792
|
+
import re as _re
|
|
793
|
+
return _re.sub(r"-\d+$", "", str(name or "")) or str(name or "")
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _describe(pod: dict) -> str:
|
|
797
|
+
age = _pod_age_hours(pod)
|
|
798
|
+
return (f"id={pod.get('id')} name={pod.get('name')!r} "
|
|
799
|
+
f"desired={pod.get('desiredStatus')} runtime={'yes' if pod.get('runtime') else 'None'} "
|
|
800
|
+
f"gpus={pod.get('gpuCount')} age={'%.2fh' % age if age is not None else 'unknown'} "
|
|
801
|
+
f"cost/hr={pod.get('costPerHr')}")
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
# ---- §A9: credentials come from the registry, not the environment ---------------------------
|
|
805
|
+
#
|
|
806
|
+
# THE SPLIT THIS CLOSES (plan 28 §A9, done-criteria at 28:108). The encrypted registry in Neon was
|
|
807
|
+
# built, populated and correct — and had exactly one caller, the provider-doctor. Every path that
|
|
808
|
+
# actually launched a machine read `os.environ`. So the diagnostic read the database and the
|
|
809
|
+
# launcher read the environment, and the two could disagree indefinitely: a provider reported
|
|
810
|
+
# healthy while nothing could launch. `directory_control.py` warns about that exact shape.
|
|
811
|
+
#
|
|
812
|
+
# Precedence is the section's: DATABASE FIRST, env as the cold-start/local-dev fallback. A stale
|
|
813
|
+
# env var outranking a rotated DB credential is what §A9 exists to remove.
|
|
814
|
+
|
|
815
|
+
#: Rotation window. `_key()` is called per operation via `_sdk()`, so an uncached read would be an
|
|
816
|
+
#: HTTP round trip per SDK call. Short enough that 28:108's "rotating a credential causes no
|
|
817
|
+
#: downtime" means minutes, not "whenever this process restarts".
|
|
818
|
+
_CREDENTIAL_TTL_S = float(envvar("CREDENTIAL_TTL_S", "60"))
|
|
819
|
+
|
|
820
|
+
#: provider_id -> (fetched_at, {name: value}). Process-local by design: the directory is the only
|
|
821
|
+
#: service that launches, and a shared cache would be a second source of truth.
|
|
822
|
+
_CRED_CACHE: "Dict[str, tuple]" = {}
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _credential_cache_clear() -> None:
|
|
826
|
+
"""Drop everything. Tests and an explicit operator refresh."""
|
|
827
|
+
_CRED_CACHE.clear()
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _credential_cache_expire_now() -> None:
|
|
831
|
+
"""Age every entry past the TTL without discarding its value — so a refresh is attempted and
|
|
832
|
+
the LAST GOOD VALUE is still there to fall back on if that refresh fails."""
|
|
833
|
+
for pid, (_, creds) in list(_CRED_CACHE.items()):
|
|
834
|
+
_CRED_CACHE[pid] = (0.0, creds)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _resolve_provider_credentials(provider_id: str) -> Dict[str, str]:
|
|
838
|
+
"""The boundary: one registry read for one provider. Patched wholesale in tests.
|
|
839
|
+
|
|
840
|
+
Kept as a module-level function rather than inlined so there is exactly one place the registry
|
|
841
|
+
is consulted from the launch path — the same reason this file is the single door for RunPod.
|
|
842
|
+
"""
|
|
843
|
+
from . import providers as _reg
|
|
844
|
+
row = next((r for r in _reg.load_seed() if r.id == provider_id), None)
|
|
845
|
+
if row is None:
|
|
846
|
+
return {}
|
|
847
|
+
return _reg.resolve_credentials(row, _reg.fetch_credentials(provider_id))
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _provider_credential(provider_id: str, name: str, *, env_name: "str | None" = None) -> str:
|
|
851
|
+
"""One credential, database first, env second, cached for `_CREDENTIAL_TTL_S`.
|
|
852
|
+
|
|
853
|
+
A FAILED OR EMPTY REFRESH NEVER EVICTS A GOOD VALUE. `fetch_credentials` returns `{}` on any
|
|
854
|
+
failure by design — including failures it cannot distinguish from "no rows" — so an empty
|
|
855
|
+
answer is not evidence that a credential was deleted. Treating it as such would turn a network
|
|
856
|
+
blip into "every provider unconfigured", silently, which is the degradation shape this
|
|
857
|
+
codebase keeps finding. The cache is therefore last-known-good, not a mirror.
|
|
858
|
+
"""
|
|
859
|
+
env_name = env_name or name
|
|
860
|
+
now = time.monotonic()
|
|
861
|
+
cached = _CRED_CACHE.get(provider_id)
|
|
862
|
+
if cached is not None and (now - cached[0]) < _CREDENTIAL_TTL_S:
|
|
863
|
+
creds = cached[1]
|
|
864
|
+
else:
|
|
865
|
+
try:
|
|
866
|
+
fresh = _resolve_provider_credentials(provider_id) or {}
|
|
867
|
+
except Exception as e:
|
|
868
|
+
log.warning("credential refresh for %s failed (%s) — serving the last known value",
|
|
869
|
+
provider_id, type(e).__name__)
|
|
870
|
+
fresh = {}
|
|
871
|
+
if fresh:
|
|
872
|
+
_CRED_CACHE[provider_id] = (now, fresh)
|
|
873
|
+
creds = fresh
|
|
874
|
+
elif cached is not None:
|
|
875
|
+
# Keep the value, reset the clock so we do not hammer a failing source every call.
|
|
876
|
+
_CRED_CACHE[provider_id] = (now, cached[1])
|
|
877
|
+
creds = cached[1]
|
|
878
|
+
else:
|
|
879
|
+
creds = {}
|
|
880
|
+
value = str(creds.get(name, "") or "").strip()
|
|
881
|
+
if value:
|
|
882
|
+
return value
|
|
883
|
+
# §A9.3 / §O2: the env read survives as the cold-start / local-dev fallback. What is removed
|
|
884
|
+
# is the env OUTRANKING the database, not the env existing.
|
|
885
|
+
#
|
|
886
|
+
# THIS GATE WAS ABSENT AND THAT WAS LOAD-BEARING. Until §O1 landed, every registry read failed
|
|
887
|
+
# with EnvelopeError, so this unconditional fallback was the only reason the platform launched
|
|
888
|
+
# anything at all. With the registry path working, an ungated fallback becomes the bug it was
|
|
889
|
+
# written to prevent: a credential rotated in the database would never take effect, because a
|
|
890
|
+
# stale environment variable would answer first and nothing would say so.
|
|
891
|
+
#
|
|
892
|
+
# `providers.ENV_FALLBACK` is read at CALL time, not bound at import, so the flag can be
|
|
893
|
+
# changed (and tested) without reimporting the module.
|
|
894
|
+
from . import providers as _reg_flag
|
|
895
|
+
if not getattr(_reg_flag, "ENV_FALLBACK", False):
|
|
896
|
+
return ""
|
|
897
|
+
return os.environ.get(env_name, "").strip()
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def provider_credential(provider_id: str, name: str, *, env_name: "str | None" = None) -> str:
|
|
901
|
+
"""THE PUBLIC DOOR for one provider credential: database first, env second, cached.
|
|
902
|
+
|
|
903
|
+
WHY THIS EXISTS AS A SEPARATE NAME (BACKEND ask, 2026-10-17). `_provider_credential` below is
|
|
904
|
+
the implementation and its leading underscore says "internal to this module" — but three
|
|
905
|
+
modules outside it were already reaching past that underscore (`serving.py`, `nodewake.py`,
|
|
906
|
+
`runpod_templates.py`). A private name with external callers is not private; it is an
|
|
907
|
+
undocumented API with a discouraging prefix, and the next person to refactor the private one
|
|
908
|
+
breaks three files with no signal that they were a contract.
|
|
909
|
+
|
|
910
|
+
So the contract is named. This is what code outside `gpuproviders` should call, and the §O
|
|
911
|
+
precedence it guarantees is the whole point of routing through here rather than reading
|
|
912
|
+
`os.environ`: file 28 §O made the DATABASE the source of truth, so a direct env read answers
|
|
913
|
+
"absent" for any deployment whose credential lives in the DB — which is the silent degradation
|
|
914
|
+
`tests/test_provider_credential_env_bypass_guard.py` exists to prevent.
|
|
915
|
+
|
|
916
|
+
IT DELEGATES, AND IT MUST NOT BECOME AN ALIAS. `provider_credential = _provider_credential`
|
|
917
|
+
would bind the implementation object at import time, so the four suites that monkeypatch
|
|
918
|
+
`gpuproviders._provider_credential` (alibaba, phala, and others) would patch the private name
|
|
919
|
+
while this public one went on calling the original — tests that pass while testing nothing.
|
|
920
|
+
One extra stack frame buys that correctness; please leave it.
|
|
921
|
+
"""
|
|
922
|
+
return _provider_credential(provider_id, name, env_name=env_name)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
#: The most GPUs one pod may be created with. RunPod's own ceiling is 8 for the multi-GPU types
|
|
926
|
+
#: this account uses; the cap is here so a mis-typed `gpuCount` cannot become an hourly bill.
|
|
927
|
+
MAX_GPU_COUNT = int(envvar("POD_MAX_GPU_COUNT", "8"))
|
|
928
|
+
|
|
929
|
+
#: Env keys that are PER POD and must never reach the template hash.
|
|
930
|
+
#:
|
|
931
|
+
#: This was the bug that stopped the fleet booting. `nodewake` injects `STEVAI_NODE_ID` into each
|
|
932
|
+
#: pod's env, so the projection hashed to a DIFFERENT name for every pod: `find_template_id` could
|
|
933
|
+
#: never match, `create_template` was attempted with a brand-new name every time, and the function
|
|
934
|
+
#: minted a template per attempt — the exact thing its own docstring says must not happen. The
|
|
935
|
+
#: leftover `frutebox-node-*` templates on the account are that residue. When template creation
|
|
936
|
+
#: then started failing, the guard refused every create and NOTHING COULD START.
|
|
937
|
+
#:
|
|
938
|
+
#: The rest of the env stays in the hash for the reason the docstring gives: a changed gateway URL
|
|
939
|
+
#: or node credential must not resolve to a template carrying the old one. Only the values that
|
|
940
|
+
#: are per-pod by construction come out.
|
|
941
|
+
_PER_POD_ENV = ("STEVAI_NODE_ID", "FRUTEBOX_NODE_ID")
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _template_env(env: dict, dedicated: bool = False) -> dict:
|
|
945
|
+
"""The env a TEMPLATE carries: everything except what differs between two pods of one spec.
|
|
946
|
+
|
|
947
|
+
`dedicated=True` (a NAMED VARIANT — see `PodSpec.dedicated_template`) keeps the per-pod keys,
|
|
948
|
+
because a variant is one pod whose identity is the point of it. One variant = one template.
|
|
949
|
+
|
|
950
|
+
WHICH LAYER WINS IS NO LONGER A QUESTION ANYONE HAS TO ANSWER. `create_pod` sends `env=` and
|
|
951
|
+
`template_id=` together and RunPod does not document which it prefers; the old code put
|
|
952
|
+
DIFFERENT values on the two layers and therefore depended on that answer. Now this one
|
|
953
|
+
function is the single source for the template's env, and it is used BOTH to name the template
|
|
954
|
+
and to create it, so the two layers either agree or the template is not reused. Whichever
|
|
955
|
+
RunPod prefers, it prefers the same value.
|
|
956
|
+
|
|
957
|
+
THE BUG THAT MADE THIS NECESSARY: the name hashed `_template_env(...)` while the content was
|
|
958
|
+
created from the FULL `spec.env`. A template's baked env therefore did not match its own name,
|
|
959
|
+
so a template minted by one pod was reused by another whose per-pod values differed — and the
|
|
960
|
+
media pod matched a pre-existing template created without any `STEVAI_NODE_ID`, booted on
|
|
961
|
+
`entrypoint.sh`'s `runpod-node-1` default, collided with the live node and advertised text.
|
|
962
|
+
"""
|
|
963
|
+
if dedicated:
|
|
964
|
+
return dict(env or {})
|
|
965
|
+
return {k: v for k, v in (env or {}).items() if k not in _PER_POD_ENV}
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
#: The last error each GPU type's create returned, so a refusal can say WHY rather than guess.
|
|
969
|
+
#: Keyed by type id; overwritten per attempt, which is all a diagnostic needs.
|
|
970
|
+
_LAST_CREATE_ERROR: Dict[str, str] = {}
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
class RunPodProvider:
|
|
974
|
+
name = "runpod"
|
|
975
|
+
#: GraphQL endpoint — used ONLY for the price query, for which the SDK has no call. Everything
|
|
976
|
+
#: with a lifecycle (create/get/terminate) goes through the SDK: hand-rolling the pod query
|
|
977
|
+
#: once produced a readiness predicate that could never pass (KB incident 2026-08-30).
|
|
978
|
+
_GQL = "https://api.runpod.io/graphql"
|
|
979
|
+
|
|
980
|
+
def _key(self) -> str:
|
|
981
|
+
return _provider_credential("runpod", "RUNPOD_API_KEY")
|
|
982
|
+
|
|
983
|
+
def configured(self) -> bool:
|
|
984
|
+
return bool(self._key())
|
|
985
|
+
|
|
986
|
+
def _sdk(self):
|
|
987
|
+
"""The configured SDK, or None. None is just another reason a bring-up cannot happen —
|
|
988
|
+
it must never crash the control plane."""
|
|
989
|
+
try:
|
|
990
|
+
import runpod as _rp
|
|
991
|
+
except Exception:
|
|
992
|
+
log.warning("runpod SDK not installed")
|
|
993
|
+
return None
|
|
994
|
+
key = self._key()
|
|
995
|
+
if not key:
|
|
996
|
+
return None
|
|
997
|
+
_rp.api_key = key
|
|
998
|
+
return _rp
|
|
999
|
+
|
|
1000
|
+
# -- observations ------------------------------------------------------------------------
|
|
1001
|
+
|
|
1002
|
+
async def get_pod(self, pod_id: str) -> "dict | None":
|
|
1003
|
+
"""One pod. None means RunPod no longer knows it — the signal a reap waits for.
|
|
1004
|
+
|
|
1005
|
+
A transport/SDK error returns {} instead: "I could not ask" must never be mistaken for
|
|
1006
|
+
"it is gone", or a reaper declares a billing pod dead on a network blip.
|
|
1007
|
+
"""
|
|
1008
|
+
rp = self._sdk()
|
|
1009
|
+
if rp is None:
|
|
1010
|
+
return {}
|
|
1011
|
+
try:
|
|
1012
|
+
import asyncio as _a
|
|
1013
|
+
return await _a.wait_for(_a.to_thread(rp.get_pod, pod_id), timeout=45)
|
|
1014
|
+
except Exception as e:
|
|
1015
|
+
msg = str(e).lower()
|
|
1016
|
+
if "not found" in msg or "does not exist" in msg:
|
|
1017
|
+
return None
|
|
1018
|
+
log.warning("runpod get_pod %s: %s", pod_id, type(e).__name__)
|
|
1019
|
+
return {}
|
|
1020
|
+
|
|
1021
|
+
async def list_pods(self) -> List[dict]:
|
|
1022
|
+
"""Every pod on the account, whatever its state. For planning and teardown."""
|
|
1023
|
+
rp = self._sdk()
|
|
1024
|
+
if rp is None:
|
|
1025
|
+
return []
|
|
1026
|
+
try:
|
|
1027
|
+
import asyncio as _a
|
|
1028
|
+
return list(await _a.wait_for(_a.to_thread(rp.get_pods), timeout=45) or [])
|
|
1029
|
+
except Exception as e:
|
|
1030
|
+
log.warning("runpod get_pods: %s", type(e).__name__)
|
|
1031
|
+
return []
|
|
1032
|
+
|
|
1033
|
+
async def list_live(self) -> List[dict]:
|
|
1034
|
+
"""Pods that are actually UP: desiredStatus RUNNING **and** a runtime object.
|
|
1035
|
+
|
|
1036
|
+
`desiredStatus` alone is a statement of intent RunPod sets the moment a record exists —
|
|
1037
|
+
counting on it is how a corpse held the pool's only slot for a night.
|
|
1038
|
+
"""
|
|
1039
|
+
return [p for p in await self.list_pods()
|
|
1040
|
+
if str(p.get("desiredStatus", "")).upper() == "RUNNING" and p.get("runtime")]
|
|
1041
|
+
|
|
1042
|
+
async def _list_pods_or_none(self) -> "Optional[List[dict]]":
|
|
1043
|
+
"""The account's pods, or None when the ASK ITSELF failed.
|
|
1044
|
+
|
|
1045
|
+
`list_pods` flattens a transport error to `[]`, which is right for planning and fatal for
|
|
1046
|
+
reaping: an empty list would read as "no orphans" on exactly the network blip where you
|
|
1047
|
+
most need to look again. The reaper must be able to tell "nothing there" from "I could
|
|
1048
|
+
not see".
|
|
1049
|
+
"""
|
|
1050
|
+
rp = self._sdk()
|
|
1051
|
+
if rp is None:
|
|
1052
|
+
return None
|
|
1053
|
+
try:
|
|
1054
|
+
import asyncio as _a
|
|
1055
|
+
return list(await _a.wait_for(_a.to_thread(rp.get_pods), timeout=45) or [])
|
|
1056
|
+
except Exception as e:
|
|
1057
|
+
log.error("reap: could not list pods (%s) — CANNOT conclude the account is clean",
|
|
1058
|
+
type(e).__name__)
|
|
1059
|
+
return None
|
|
1060
|
+
|
|
1061
|
+
# -- the reaper ---------------------------------------------------------------------------
|
|
1062
|
+
|
|
1063
|
+
async def reap(self, tracker: "Optional[PodTracker]" = None, *, owns: str = "",
|
|
1064
|
+
dry_run: bool = False, reap_unowned: bool = False,
|
|
1065
|
+
settle_s: float = 0.0) -> dict:
|
|
1066
|
+
"""Reconcile against RunPod's own list and terminate what nothing is holding.
|
|
1067
|
+
|
|
1068
|
+
`tracker` supplies the kept set; without one, NOTHING is kept — that is the standalone
|
|
1069
|
+
sweep. `owns` scopes which names may be terminated (see the ownership note below);
|
|
1070
|
+
`reap_unowned=True` drops that scope and kills everything unkept.
|
|
1071
|
+
|
|
1072
|
+
`settle_s` > 0 re-lists until it stops finding new pods, for the case this whole class
|
|
1073
|
+
exists for: a create whose call timed out, whose pod appears a minute later.
|
|
1074
|
+
|
|
1075
|
+
OWNERSHIP. By default the reaper only TERMINATES pods whose name starts with `owns`;
|
|
1076
|
+
anything else it reports loudly but leaves alone. That protects the two kinds of pod a
|
|
1077
|
+
blanket reap would destroy: `diag_create` pods, which exist precisely so the owner can
|
|
1078
|
+
read boot failures in the console (the API exposes no pod logs), and anything the owner
|
|
1079
|
+
started by hand. Unowned pods are never silently ignored — they are logged as ORPHAN? and
|
|
1080
|
+
returned in `unowned`, so a sweep still surfaces them.
|
|
1081
|
+
"""
|
|
1082
|
+
import asyncio as _a
|
|
1083
|
+
kept = set(tracker.kept) if tracker else set()
|
|
1084
|
+
scope = owns or (tracker.owns if tracker else "")
|
|
1085
|
+
|
|
1086
|
+
pods = await self._list_pods_or_none()
|
|
1087
|
+
if pods is None:
|
|
1088
|
+
return {"ok": False, "error": "could not list pods", "terminated": [],
|
|
1089
|
+
"kept": sorted(kept), "unowned": []}
|
|
1090
|
+
|
|
1091
|
+
# Settle: an ambiguous create can land AFTER the call that made it returned.
|
|
1092
|
+
if settle_s > 0:
|
|
1093
|
+
deadline = time.monotonic() + settle_s
|
|
1094
|
+
known = {str(x.get("id")) for x in pods}
|
|
1095
|
+
while time.monotonic() < deadline:
|
|
1096
|
+
await _a.sleep(_SETTLE_POLL_S)
|
|
1097
|
+
again = await self._list_pods_or_none()
|
|
1098
|
+
if again is None:
|
|
1099
|
+
continue
|
|
1100
|
+
fresh = {str(x.get("id")) for x in again} - known
|
|
1101
|
+
pods = again
|
|
1102
|
+
if fresh:
|
|
1103
|
+
log.warning("reap: %d pod(s) appeared AFTER the create returned: %s",
|
|
1104
|
+
len(fresh), ", ".join(sorted(fresh)))
|
|
1105
|
+
known |= fresh
|
|
1106
|
+
deadline = time.monotonic() + settle_s # something moved; watch again
|
|
1107
|
+
|
|
1108
|
+
terminated, failed, unowned, suspected = [], [], [], []
|
|
1109
|
+
for pod in pods:
|
|
1110
|
+
pid, pname = str(pod.get("id") or ""), str(pod.get("name") or "")
|
|
1111
|
+
if not pid or pid in kept:
|
|
1112
|
+
continue
|
|
1113
|
+
if scope and not reap_unowned and not pname.startswith(scope):
|
|
1114
|
+
age = _pod_age_hours(pod)
|
|
1115
|
+
# An unowned pod is left alone (a diag pod is the point of the exception) — but
|
|
1116
|
+
# "left alone" must never mean "quietly tolerated". Past the suspect age it is
|
|
1117
|
+
# escalated: ERROR, and carried out in `suspected_orphans` so the caller's exit
|
|
1118
|
+
# status can reflect it. A 13-hour pod in a reported bucket is how this cost $6.54.
|
|
1119
|
+
old_enough = age is None or age >= _ORPHAN_SUSPECT_AGE_H
|
|
1120
|
+
entry = {"id": pid, "name": pname, "age_h": age}
|
|
1121
|
+
if old_enough:
|
|
1122
|
+
log.error("reap: SUSPECTED ORPHAN — not mine, %s old, LEAVING IT but this "
|
|
1123
|
+
"needs a human: %s. Reap it with: python -m frutebox.podreaper "
|
|
1124
|
+
"--sweep --all",
|
|
1125
|
+
("unknown age" if age is None else "%.2fh" % age), _describe(pod))
|
|
1126
|
+
suspected.append(entry)
|
|
1127
|
+
else:
|
|
1128
|
+
log.warning("reap: not mine, leaving it (only %.2fh old): %s",
|
|
1129
|
+
age, _describe(pod))
|
|
1130
|
+
unowned.append(entry)
|
|
1131
|
+
continue
|
|
1132
|
+
if dry_run:
|
|
1133
|
+
log.warning("reap: WOULD TERMINATE (dry run): %s", _describe(pod))
|
|
1134
|
+
terminated.append(pid)
|
|
1135
|
+
continue
|
|
1136
|
+
log.error("reap: TERMINATING untracked pod: %s", _describe(pod))
|
|
1137
|
+
(terminated if await self.terminate(pid) else failed).append(pid)
|
|
1138
|
+
|
|
1139
|
+
if terminated and not dry_run:
|
|
1140
|
+
log.error("reap: terminated %d untracked pod(s): %s — a pod reached this point "
|
|
1141
|
+
"because nothing confirmed it was wanted", len(terminated),
|
|
1142
|
+
", ".join(terminated))
|
|
1143
|
+
if suspected:
|
|
1144
|
+
log.error("reap: %d SUSPECTED ORPHAN(S) older than %.0f min left running: %s",
|
|
1145
|
+
len(suspected), _ORPHAN_SUSPECT_AGE_H * 60,
|
|
1146
|
+
", ".join(x["id"] for x in suspected))
|
|
1147
|
+
return {"ok": not failed, "terminated": terminated, "kept": sorted(kept),
|
|
1148
|
+
"unowned": unowned, "suspected_orphans": suspected,
|
|
1149
|
+
**({"failed": failed} if failed else {})}
|
|
1150
|
+
|
|
1151
|
+
@contextlib.asynccontextmanager
|
|
1152
|
+
async def creation_guard(self, owns: str, *, dry_run: bool = False):
|
|
1153
|
+
"""Wrap every window in which a pod may be created. Yields a `PodTracker`.
|
|
1154
|
+
|
|
1155
|
+
The reap runs in a `finally`: an exception, a timeout, a cancelled task or a
|
|
1156
|
+
KeyboardInterrupt cannot skip it. Anything the caller did not `keep()` dies.
|
|
1157
|
+
|
|
1158
|
+
async with rp.creation_guard("frutebox-runpod") as t:
|
|
1159
|
+
pod_id = await rp.create_pod(spec, gpu, cloud)
|
|
1160
|
+
if pod_id and await rp.wait_ready(pod_id, spec):
|
|
1161
|
+
t.keep(pod_id) # <- the ONLY thing that spares it
|
|
1162
|
+
"""
|
|
1163
|
+
tracker = PodTracker(owns)
|
|
1164
|
+
token = _TRACKER.set(tracker)
|
|
1165
|
+
try:
|
|
1166
|
+
yield tracker
|
|
1167
|
+
finally:
|
|
1168
|
+
_TRACKER.reset(token)
|
|
1169
|
+
try:
|
|
1170
|
+
# An ambiguous create means a pod may still be on its way: wait for it.
|
|
1171
|
+
settle = _SETTLE_S if tracker.ambiguous else 0.0
|
|
1172
|
+
result = await self.reap(tracker, dry_run=dry_run, settle_s=settle)
|
|
1173
|
+
if result.get("terminated"):
|
|
1174
|
+
log.error("creation_guard(%s): reaped %s", owns, result["terminated"])
|
|
1175
|
+
# A suspected orphan is not this window's to kill, but the NEXT wake must not be
|
|
1176
|
+
# able to proceed as if the account were clean. Recorded where the caller sees it.
|
|
1177
|
+
if result.get("suspected_orphans"):
|
|
1178
|
+
_SUSPECTED.extend(result["suspected_orphans"])
|
|
1179
|
+
log.error("creation_guard(%s): %d suspected orphan(s) still running — "
|
|
1180
|
+
"BILLING. %s", owns, len(result["suspected_orphans"]),
|
|
1181
|
+
", ".join(x["id"] for x in result["suspected_orphans"]))
|
|
1182
|
+
except Exception as e: # a failing reaper must still be LOUD
|
|
1183
|
+
log.error("creation_guard(%s): REAP FAILED (%s) — pods MAY BE BILLING, sweep "
|
|
1184
|
+
"with: python -m frutebox.podreaper --sweep", owns, type(e).__name__)
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
@staticmethod
|
|
1188
|
+
def pod_runtime_ready(pod: "dict | None") -> bool:
|
|
1189
|
+
"""PURE. A live `runtime` with mapped ports — never `desiredStatus`.
|
|
1190
|
+
|
|
1191
|
+
Deliberately reads ONLY fields the SDK's pod query selects (`runtime { ports {...} }`).
|
|
1192
|
+
A predicate that once required `runtime.uptimeInSeconds` — a field the query cannot
|
|
1193
|
+
return — was structurally impossible to satisfy and reaped six healthy pods in a night.
|
|
1194
|
+
The guard test asserts every field read here appears in the SDK's own query text.
|
|
1195
|
+
"""
|
|
1196
|
+
if not pod:
|
|
1197
|
+
return False
|
|
1198
|
+
rt = pod.get("runtime") or {}
|
|
1199
|
+
return bool(rt) and bool(rt.get("ports"))
|
|
1200
|
+
|
|
1201
|
+
@staticmethod
|
|
1202
|
+
def proxy_url(pod_id: str, port: int) -> str:
|
|
1203
|
+
return f"https://{pod_id}-{port}.proxy.runpod.net"
|
|
1204
|
+
|
|
1205
|
+
async def serves(self, pod_id: str, port: int, path: str = "/") -> bool:
|
|
1206
|
+
"""A REAL request to the served port — not a TCP probe.
|
|
1207
|
+
|
|
1208
|
+
The User-Agent matters: RunPod's proxy rejects requests that do not look like a browser
|
|
1209
|
+
or curl, which once made a stream of perfectly healthy pods look dead.
|
|
1210
|
+
|
|
1211
|
+
`path` comes from `PodSpec.health_path`, because "what counts as answering" is a property
|
|
1212
|
+
of the SERVER, not of this door — see that field for the engine that has no `/`.
|
|
1213
|
+
"""
|
|
1214
|
+
try:
|
|
1215
|
+
async with httpx.AsyncClient(timeout=20) as c:
|
|
1216
|
+
r = await c.get(f"{self.proxy_url(pod_id, port)}{path or '/'}",
|
|
1217
|
+
headers={"user-agent": "curl/8.4.0"})
|
|
1218
|
+
return r.status_code == 200
|
|
1219
|
+
except Exception:
|
|
1220
|
+
return False
|
|
1221
|
+
|
|
1222
|
+
# -- lifecycle ---------------------------------------------------------------------------
|
|
1223
|
+
|
|
1224
|
+
async def wait_ready(self, pod_id: str, spec: PodSpec,
|
|
1225
|
+
extra_ready: "Optional[Callable[[], bool]]" = None) -> bool:
|
|
1226
|
+
"""Poll until the pod has a live runtime AND serves, or give up.
|
|
1227
|
+
|
|
1228
|
+
`extra_ready` lets the CALLER add its own success signal (the pool passes "a node
|
|
1229
|
+
registered with the directory", the strongest possible proof for our own image) without
|
|
1230
|
+
this module knowing anything about directories.
|
|
1231
|
+
|
|
1232
|
+
Returns False rather than raising: the caller's next move is reap-and-fallback, and an
|
|
1233
|
+
exception here would skip the reap and leave a billing pod behind.
|
|
1234
|
+
"""
|
|
1235
|
+
import asyncio as _a
|
|
1236
|
+
waited = 0.0
|
|
1237
|
+
while waited < spec.ready_timeout_s:
|
|
1238
|
+
pod = await self.get_pod(pod_id)
|
|
1239
|
+
if pod is None:
|
|
1240
|
+
log.warning("pod %s vanished while waiting for readiness", pod_id)
|
|
1241
|
+
return False
|
|
1242
|
+
status = str(pod.get("desiredStatus", "")).upper()
|
|
1243
|
+
if status and status != "RUNNING":
|
|
1244
|
+
log.warning("pod %s left RUNNING (%s) before becoming ready", pod_id, status)
|
|
1245
|
+
return False
|
|
1246
|
+
if self.pod_runtime_ready(pod):
|
|
1247
|
+
if extra_ready is not None and extra_ready():
|
|
1248
|
+
log.info("pod %s READY after %.0fs (registered)", pod_id, waited)
|
|
1249
|
+
return True
|
|
1250
|
+
if await self.serves(pod_id, spec.serving_port, spec.health_path):
|
|
1251
|
+
log.info("pod %s READY after %.0fs (runtime live and serving)", pod_id, waited)
|
|
1252
|
+
return True
|
|
1253
|
+
await _a.sleep(spec.poll_s)
|
|
1254
|
+
waited += spec.poll_s
|
|
1255
|
+
log.warning("pod %s NOT ready after %.0fs. Reaping.", pod_id, spec.ready_timeout_s)
|
|
1256
|
+
return False
|
|
1257
|
+
|
|
1258
|
+
async def terminate(self, pod_id: str, verify_timeout_s: float = 120, poll_s: float = 5) -> bool:
|
|
1259
|
+
"""Terminate AND VERIFY the pod is gone. The API's 2xx is never the evidence — REST
|
|
1260
|
+
DELETE returns 2xx without stopping the pod, and it keeps BILLING. The terminate is
|
|
1261
|
+
re-issued each round until the pod query stops returning it."""
|
|
1262
|
+
import asyncio as _a
|
|
1263
|
+
rp = self._sdk()
|
|
1264
|
+
if rp is None:
|
|
1265
|
+
return False
|
|
1266
|
+
|
|
1267
|
+
async def _issue():
|
|
1268
|
+
try:
|
|
1269
|
+
await _a.wait_for(_a.to_thread(rp.terminate_pod, pod_id), timeout=45)
|
|
1270
|
+
except Exception as e:
|
|
1271
|
+
log.warning("runpod terminate_pod %s: %s", pod_id, type(e).__name__)
|
|
1272
|
+
|
|
1273
|
+
await _issue()
|
|
1274
|
+
waited = 0.0
|
|
1275
|
+
while waited < verify_timeout_s:
|
|
1276
|
+
pod = await self.get_pod(pod_id)
|
|
1277
|
+
if pod is None:
|
|
1278
|
+
log.info("pod %s terminated and verified gone", pod_id)
|
|
1279
|
+
return True
|
|
1280
|
+
await _a.sleep(poll_s)
|
|
1281
|
+
waited += poll_s
|
|
1282
|
+
await _issue() # RunPod sometimes needs it more than once
|
|
1283
|
+
log.error("PANIC: pod %s still present %.0fs after terminate — MAY BE BILLING",
|
|
1284
|
+
pod_id, verify_timeout_s)
|
|
1285
|
+
return False
|
|
1286
|
+
|
|
1287
|
+
async def _template_id_for(self, spec: PodSpec) -> "str | None":
|
|
1288
|
+
"""The RunPod template that carries this spec's registry credential, found or created.
|
|
1289
|
+
|
|
1290
|
+
NO CACHE, DELIBERATELY. The name is a hash of the spec's own contents, so the same spec
|
|
1291
|
+
always resolves to the same name; we re-find the template by ASKING RunPod rather than by
|
|
1292
|
+
remembering an id. A remembered id would be a second place holding something derived from
|
|
1293
|
+
the spec, and it would drift the moment the spec changed and the memory did not.
|
|
1294
|
+
|
|
1295
|
+
Measured 2026-09-20: `create_template` with a name that already exists FAILS with
|
|
1296
|
+
"Template name must be unique." — it does not return the existing id and does not make a
|
|
1297
|
+
duplicate. So the find MUST come first, templates cannot leak, and the count is bounded
|
|
1298
|
+
server-side by the number of distinct specs.
|
|
1299
|
+
"""
|
|
1300
|
+
import asyncio as _a
|
|
1301
|
+
from . import runpod_templates as rt
|
|
1302
|
+
|
|
1303
|
+
rp = self._sdk()
|
|
1304
|
+
if rp is None:
|
|
1305
|
+
return None
|
|
1306
|
+
# The projection: only what the template actually carries. Not the pod name, not the GPU —
|
|
1307
|
+
# those vary per attempt and would mint a template per attempt.
|
|
1308
|
+
projection = {
|
|
1309
|
+
"image_name": spec.image,
|
|
1310
|
+
"container_disk_in_gb": spec.container_disk_gb,
|
|
1311
|
+
"ports": ports_string(spec),
|
|
1312
|
+
"registry_auth_id": spec.container_registry_auth_id,
|
|
1313
|
+
# THE ENV GOES ON THE TEMPLATE TOO, and it is part of the name.
|
|
1314
|
+
#
|
|
1315
|
+
# `create_pod` sends `env=` and `template_id=` in the same call, and which layer wins
|
|
1316
|
+
# is not documented. That is not a question worth renting a pod to answer: putting the
|
|
1317
|
+
# env on BOTH means the node boots with its credential and its gateway URL either way.
|
|
1318
|
+
# Whichever layer RunPod prefers, it prefers the same values.
|
|
1319
|
+
#
|
|
1320
|
+
# It belongs in the hash for the same reason everything else here does — a node
|
|
1321
|
+
# credential or a gateway URL that changed while the name stayed put would resolve to
|
|
1322
|
+
# a template carrying the OLD one, and the pod would boot with stale identity. The
|
|
1323
|
+
# hash is sha256 and only the digest is ever used, so no value is exposed by it.
|
|
1324
|
+
"env": _template_env(spec.env, spec.dedicated_template),
|
|
1325
|
+
}
|
|
1326
|
+
name = rt.template_name(projection)
|
|
1327
|
+
|
|
1328
|
+
found = await _a.to_thread(rt.find_template_id, name)
|
|
1329
|
+
if found:
|
|
1330
|
+
log.info("template %s found (%s)", name, found)
|
|
1331
|
+
return found
|
|
1332
|
+
try:
|
|
1333
|
+
res = await _a.wait_for(_a.to_thread(lambda: rp.create_template(
|
|
1334
|
+
name=name,
|
|
1335
|
+
image_name=spec.image,
|
|
1336
|
+
container_disk_in_gb=spec.container_disk_gb,
|
|
1337
|
+
ports=ports_string(spec),
|
|
1338
|
+
registry_auth_id=spec.container_registry_auth_id,
|
|
1339
|
+
# THE SAME PROJECTION THAT NAMED IT. Creating with the full env while naming with
|
|
1340
|
+
# a stripped one is what let a template's content drift from its own name.
|
|
1341
|
+
env=_template_env(spec.env, spec.dedicated_template),
|
|
1342
|
+
)), timeout=60)
|
|
1343
|
+
except Exception as e:
|
|
1344
|
+
# Someone may have created it between our find and our create. Ask once more before
|
|
1345
|
+
# giving up — the uniqueness error is exactly what a race looks like.
|
|
1346
|
+
log.warning("template %s create failed (%s: %s); re-checking",
|
|
1347
|
+
name, type(e).__name__, str(e)[:160])
|
|
1348
|
+
return await _a.to_thread(rt.find_template_id, name)
|
|
1349
|
+
tid = (res or {}).get("id")
|
|
1350
|
+
log.info("template %s created (%s)", name, tid)
|
|
1351
|
+
return str(tid) if tid else None
|
|
1352
|
+
|
|
1353
|
+
async def create_pod(self, spec: PodSpec, gpu_type_id: str, cloud_type: str,
|
|
1354
|
+
data_center_id: str = "") -> "str | None":
|
|
1355
|
+
"""One create attempt. Every refusal is surfaced with RunPod's own reply — a silent None
|
|
1356
|
+
once cost three armed windows spent guessing."""
|
|
1357
|
+
import asyncio as _a
|
|
1358
|
+
rp = self._sdk()
|
|
1359
|
+
if rp is None:
|
|
1360
|
+
return None
|
|
1361
|
+
gpu_count = max(1, min(int(spec.gpu_count), MAX_GPU_COUNT))
|
|
1362
|
+
if gpu_count != int(spec.gpu_count):
|
|
1363
|
+
# A spec asking for more cards than the cap gets the cap, loudly. Silently honouring
|
|
1364
|
+
# an absurd count is how one typo becomes an hourly bill nobody chose.
|
|
1365
|
+
log.error("spec %s asked for %s GPUs; capped at %s (STEVAI_POD_MAX_GPU_COUNT)",
|
|
1366
|
+
spec.name, spec.gpu_count, gpu_count)
|
|
1367
|
+
if gpu_count > 1:
|
|
1368
|
+
# SAY IT. `from_operator_spec` now reads the operator's `gpuCount`, which it ignored
|
|
1369
|
+
# for as long as it existed — so a configured frontier spec that always produced a
|
|
1370
|
+
# 1-GPU pod suddenly produces the 8-GPU pod it always asked for. That is the intent
|
|
1371
|
+
# (a 1-GPU frontier pod cannot load the model at all), and it is an 8x hourly cost
|
|
1372
|
+
# change, so it belongs in the log at the moment the money is committed.
|
|
1373
|
+
log.warning("MULTI-GPU POD: %s x %s — hourly cost scales with the count",
|
|
1374
|
+
gpu_count, gpu_type_id)
|
|
1375
|
+
kwargs = dict(
|
|
1376
|
+
name=spec.name,
|
|
1377
|
+
image_name=spec.image,
|
|
1378
|
+
gpu_type_id=gpu_type_id,
|
|
1379
|
+
cloud_type=cloud_type,
|
|
1380
|
+
gpu_count=gpu_count,
|
|
1381
|
+
container_disk_in_gb=spec.container_disk_gb,
|
|
1382
|
+
volume_in_gb=spec.volume_gb,
|
|
1383
|
+
ports=ports_string(spec),
|
|
1384
|
+
docker_args=spec.docker_args,
|
|
1385
|
+
support_public_ip=True,
|
|
1386
|
+
env=dict(spec.env),
|
|
1387
|
+
# THE FIX for "RUNNING but no runtime, ever": without a CUDA filter the pod can land
|
|
1388
|
+
# on an old-driver host where the container fails OCI-create BEFORE any process starts
|
|
1389
|
+
# (unsatisfied condition: cuda>=12.x) — billing, empty logs, no runtime.
|
|
1390
|
+
allowed_cuda_versions=[v.strip() for v in envvar("ALLOWED_CUDA", "12.4,12.5,12.6,12.7,12.8").split(",") if v.strip()],
|
|
1391
|
+
)
|
|
1392
|
+
# A PRIVATE image needs a registry credential, and create_pod takes none — it travels as
|
|
1393
|
+
# a template. Only when the spec carries one: a public image (the PROBE spec pulls
|
|
1394
|
+
# ollama/ollama:latest from Docker Hub) must go out with no template and no credential.
|
|
1395
|
+
if spec.container_registry_auth_id:
|
|
1396
|
+
template_id = await self._template_id_for(spec)
|
|
1397
|
+
if not template_id:
|
|
1398
|
+
log.error("create REFUSED name=%s: the spec needs a registry credential but no "
|
|
1399
|
+
"template could be found or created — a pod without one is rented and "
|
|
1400
|
+
"exits in seconds", spec.name)
|
|
1401
|
+
return None
|
|
1402
|
+
kwargs["template_id"] = template_id
|
|
1403
|
+
if spec.network_volume_id:
|
|
1404
|
+
kwargs["network_volume_id"] = spec.network_volume_id
|
|
1405
|
+
# A network volume is DATACENTER-LOCKED: when one is attached, the volume's datacenter
|
|
1406
|
+
# overrides whatever the fallback entry says — deploying anywhere else cannot mount it,
|
|
1407
|
+
# which is a pod that bills and serves nothing. Without a volume, the entry's DC (if any)
|
|
1408
|
+
# applies, else placement stays free.
|
|
1409
|
+
dc = (spec.data_center_id if spec.network_volume_id else "") \
|
|
1410
|
+
or data_center_id or spec.data_center_id
|
|
1411
|
+
if dc:
|
|
1412
|
+
kwargs["data_center_id"] = dc
|
|
1413
|
+
log.info("create attempt: name=%s gpu=%sx%s cloud=%s dc=%s",
|
|
1414
|
+
spec.name, kwargs["gpu_count"], gpu_type_id, cloud_type,
|
|
1415
|
+
kwargs.get("data_center_id", "-"))
|
|
1416
|
+
# Who was on the account BEFORE we asked. The only way to tell an ambiguous create's pod
|
|
1417
|
+
# from someone else's is to know what was already there.
|
|
1418
|
+
before = {str(x.get("id")) for x in (await self._list_pods_or_none() or [])}
|
|
1419
|
+
tracker = _TRACKER.get()
|
|
1420
|
+
if tracker is None:
|
|
1421
|
+
log.warning("create_pod(%s) is running OUTSIDE a creation_guard — if this call times "
|
|
1422
|
+
"out, nothing will reap what it may have created", spec.name)
|
|
1423
|
+
try:
|
|
1424
|
+
# BOUNDED. On 2026-08-30 a create call hung inside the SDK with no timeout, holding
|
|
1425
|
+
# the reconcile lock — one pool-plan log line, then silence, while every tick skipped
|
|
1426
|
+
# as busy and the operator had no window into why. A stuck transport call is a
|
|
1427
|
+
# REFUSAL, loudly, never a wedge.
|
|
1428
|
+
pod = await _a.wait_for(_a.to_thread(lambda: rp.create_pod(**kwargs)), timeout=180)
|
|
1429
|
+
except Exception as e:
|
|
1430
|
+
# NOT "nothing was created". `wait_for` cancels the AWAIT, not the blocking SDK call
|
|
1431
|
+
# running in the worker thread — that request is still in flight and RunPod may honour
|
|
1432
|
+
# it seconds or minutes from now. On 2026-09-17 this exact path returned None, the
|
|
1433
|
+
# caller moved to the next fallback, the process exited, and the pod that arrived
|
|
1434
|
+
# afterwards billed for 13h20m. Reconcile against RunPod's list; do not guess.
|
|
1435
|
+
_LAST_CREATE_ERROR[gpu_type_id] = f"{type(e).__name__}: {str(e)[:160]}"
|
|
1436
|
+
log.error("create AMBIGUOUS gpu=%s cloud=%s: %s: %s — a pod MAY have been created; "
|
|
1437
|
+
"reconciling against RunPod's list", gpu_type_id, cloud_type,
|
|
1438
|
+
type(e).__name__, str(e)[:300])
|
|
1439
|
+
if tracker is not None:
|
|
1440
|
+
tracker.ambiguous.add(spec.name)
|
|
1441
|
+
pod_id = await self._find_created_after(spec.name, before)
|
|
1442
|
+
if pod_id:
|
|
1443
|
+
log.error("create AMBIGUOUS but a pod DID appear: %s — adopting it so the normal "
|
|
1444
|
+
"ready-or-reap path owns it", pod_id)
|
|
1445
|
+
if tracker is not None:
|
|
1446
|
+
tracker.saw(pod_id)
|
|
1447
|
+
return pod_id
|
|
1448
|
+
# Still nothing. The guard's exit reap re-checks with a settle window, because it can
|
|
1449
|
+
# still show up after we return.
|
|
1450
|
+
return None
|
|
1451
|
+
pod_id = (pod or {}).get("id")
|
|
1452
|
+
if not pod_id:
|
|
1453
|
+
log.warning("create refused gpu=%s cloud=%s reply=%s",
|
|
1454
|
+
gpu_type_id, cloud_type, str(pod)[:300])
|
|
1455
|
+
elif tracker is not None:
|
|
1456
|
+
tracker.saw(pod_id)
|
|
1457
|
+
return pod_id
|
|
1458
|
+
|
|
1459
|
+
async def _find_created_after(self, name: str, before: "Set[str]",
|
|
1460
|
+
window_s: "float | None" = None,
|
|
1461
|
+
poll_s: "float | None" = None) -> "str | None":
|
|
1462
|
+
"""Poll RunPod for a pod named `name` that was not on the account before we asked.
|
|
1463
|
+
|
|
1464
|
+
Bounded, because the alternative to waiting is guessing, and guessing here costs money
|
|
1465
|
+
per hour. A pod that appears later than this window is caught by the guard's exit reap.
|
|
1466
|
+
"""
|
|
1467
|
+
import asyncio as _a
|
|
1468
|
+
window_s = _AMBIGUOUS_WINDOW_S if window_s is None else window_s
|
|
1469
|
+
poll_s = _AMBIGUOUS_POLL_S if poll_s is None else poll_s
|
|
1470
|
+
waited = 0.0
|
|
1471
|
+
while waited <= window_s:
|
|
1472
|
+
pods = await self._list_pods_or_none()
|
|
1473
|
+
if pods is not None:
|
|
1474
|
+
for pod in pods:
|
|
1475
|
+
pid = str(pod.get("id") or "")
|
|
1476
|
+
if pid and pid not in before and str(pod.get("name") or "") == name:
|
|
1477
|
+
return pid
|
|
1478
|
+
await _a.sleep(poll_s)
|
|
1479
|
+
waited += poll_s
|
|
1480
|
+
return None
|
|
1481
|
+
|
|
1482
|
+
async def create_ready(self, spec: PodSpec,
|
|
1483
|
+
extra_ready: "Optional[Callable[[], bool]]" = None) -> "str | None":
|
|
1484
|
+
"""Deploy → verify → reap → next candidate, across the spec's GPU/cloud fallbacks.
|
|
1485
|
+
|
|
1486
|
+
Returns the pod id only once it is genuinely serving. Anything that does not get there is
|
|
1487
|
+
terminated (with verification) before the next attempt, so a run of failures cannot leave
|
|
1488
|
+
a trail of billing corpses. None after the whole list = a genuine provider shortage — the
|
|
1489
|
+
caller escalates, never spins.
|
|
1490
|
+
"""
|
|
1491
|
+
# EVERY create in this method happens inside the guard, so no exit path — exhausted
|
|
1492
|
+
# fallbacks, an exception, a cancelled task — can leave a pod nothing is holding. The
|
|
1493
|
+
# only pod that survives is the one we explicitly `keep()` below.
|
|
1494
|
+
async with self.creation_guard(_pool_scope(spec.name)) as tracker:
|
|
1495
|
+
return await self._create_ready_guarded(spec, tracker, extra_ready)
|
|
1496
|
+
|
|
1497
|
+
async def _create_ready_guarded(self, spec: PodSpec, tracker: PodTracker,
|
|
1498
|
+
extra_ready: "Optional[Callable[[], bool]]" = None) -> "str | None":
|
|
1499
|
+
model = str(spec.env.get("STEVAI_OLLAMA_MODEL", "") or "")
|
|
1500
|
+
# LIVE STOCK FIRST. The static list is the candidate SET; which of them can actually be
|
|
1501
|
+
# rented right now, and in what order, is a question only the provider can answer. Fails
|
|
1502
|
+
# open to the static order — see `rank_by_availability`.
|
|
1503
|
+
ordered = await self.rank_by_availability(spec.gpu_fallbacks, model=model,
|
|
1504
|
+
disk_gb=spec.container_disk_gb)
|
|
1505
|
+
for entry in ordered:
|
|
1506
|
+
gpu_type_id, cloud_type = entry[0], entry[1]
|
|
1507
|
+
entry_dc = entry[2] if len(entry) > 2 else ""
|
|
1508
|
+
# A doomed combination is refused BEFORE money is spent. This is the assertion form of
|
|
1509
|
+
# the 2026-08-30 root cause: a tier-S model on an A40 booted, billed, and "loaded"
|
|
1510
|
+
# forever — and looked exactly like a provider fault.
|
|
1511
|
+
if model:
|
|
1512
|
+
err = model_fit_error(model, gpu_type_id,
|
|
1513
|
+
spec.gpu_count, spec.container_disk_gb)
|
|
1514
|
+
if err:
|
|
1515
|
+
log.error("REFUSING create gpu=%s cloud=%s: %s", gpu_type_id, cloud_type, err)
|
|
1516
|
+
continue
|
|
1517
|
+
pod_id = await self.create_pod(spec, gpu_type_id, cloud_type, data_center_id=entry_dc)
|
|
1518
|
+
if not pod_id:
|
|
1519
|
+
continue
|
|
1520
|
+
log.info("placed pod=%s gpu=%s cloud=%s — verifying it actually serves",
|
|
1521
|
+
pod_id, gpu_type_id, cloud_type)
|
|
1522
|
+
if await self.wait_ready(pod_id, spec, extra_ready=extra_ready):
|
|
1523
|
+
log.info("READY pod=%s gpu=%s cloud=%s", pod_id, gpu_type_id, cloud_type)
|
|
1524
|
+
tracker.keep(pod_id) # the ONLY thing that spares it from the reaper
|
|
1525
|
+
return pod_id
|
|
1526
|
+
log.warning("not ready in time; reaping pod=%s gpu=%s cloud=%s",
|
|
1527
|
+
pod_id, gpu_type_id, cloud_type)
|
|
1528
|
+
await self.terminate(pod_id)
|
|
1529
|
+
log.error("all fallbacks exhausted — genuine RunPod capacity shortage, not a retry loop")
|
|
1530
|
+
return None
|
|
1531
|
+
|
|
1532
|
+
# -- diagnostics (owner-driven boot debugging) ---------------------------------------------
|
|
1533
|
+
|
|
1534
|
+
async def rank_by_availability(self, candidates, *, model: str = "",
|
|
1535
|
+
disk_gb: int = 40) -> list:
|
|
1536
|
+
"""Order (gpu, cloud[, dc]) candidates by LIVE STOCK and price; drop what cannot be rented.
|
|
1537
|
+
|
|
1538
|
+
WHY THIS EXISTS. `gpu_fallbacks` is a static list written when it was written. On
|
|
1539
|
+
2026-09-28 every card the WARM spec pins — A40, RTX A5000, RTX A6000, L40S — was out of
|
|
1540
|
+
stock simultaneously, so a wake tried four combinations, spent ~75s on each (RunPod's
|
|
1541
|
+
"no instances available" is ambiguous, so the provider reconciles against the live pod list
|
|
1542
|
+
before giving up), and produced nothing. The same thing happening at 3am to a standby that
|
|
1543
|
+
has died is a network with no capacity and nobody watching.
|
|
1544
|
+
|
|
1545
|
+
`lowestPrice` is null for a card with no stock, so this is a real availability signal.
|
|
1546
|
+
Capacity also MOVES: L40S went from no-stock to $0.79 inside two minutes the same day,
|
|
1547
|
+
which is why this is queried per wake and never cached.
|
|
1548
|
+
|
|
1549
|
+
IT FAILS OPEN, DELIBERATELY. If the price query cannot be answered — no credential, the
|
|
1550
|
+
vendor is down — this returns the caller's original order untouched. The alternative is a
|
|
1551
|
+
wake that refuses to try because it could not read a price list, which turns a degraded
|
|
1552
|
+
signal into no capacity at all. Same for the case where every candidate reports no stock:
|
|
1553
|
+
the static order is still attempted, because a stale "no stock" must not be the reason
|
|
1554
|
+
nothing is tried.
|
|
1555
|
+
"""
|
|
1556
|
+
original = [tuple(c) for c in candidates]
|
|
1557
|
+
try:
|
|
1558
|
+
types = await self.gpu_prices()
|
|
1559
|
+
except Exception as e: # noqa: BLE001
|
|
1560
|
+
log.warning("availability: price query failed (%s) — keeping the static order",
|
|
1561
|
+
type(e).__name__)
|
|
1562
|
+
return original
|
|
1563
|
+
if not types:
|
|
1564
|
+
log.warning("availability: no price data — keeping the static order")
|
|
1565
|
+
return original
|
|
1566
|
+
|
|
1567
|
+
price: dict = {}
|
|
1568
|
+
for t in types:
|
|
1569
|
+
lp = t.get("lowestPrice") or {}
|
|
1570
|
+
best = lp.get("uninterruptablePrice") or lp.get("minimumBidPrice")
|
|
1571
|
+
if best is not None:
|
|
1572
|
+
price[str(t.get("id") or "")] = float(best)
|
|
1573
|
+
|
|
1574
|
+
in_stock, out = [], []
|
|
1575
|
+
for c in original:
|
|
1576
|
+
gpu = str(c[0])
|
|
1577
|
+
if gpu not in price:
|
|
1578
|
+
out.append(gpu)
|
|
1579
|
+
continue
|
|
1580
|
+
if model:
|
|
1581
|
+
err = model_fit_error(model, gpu, 1, disk_gb)
|
|
1582
|
+
if err:
|
|
1583
|
+
log.info("availability: skipping %s — %s", gpu, err)
|
|
1584
|
+
continue
|
|
1585
|
+
in_stock.append(c)
|
|
1586
|
+
|
|
1587
|
+
if not in_stock:
|
|
1588
|
+
log.warning("availability: none of %d candidate(s) report stock (%s) — trying the "
|
|
1589
|
+
"static order anyway", len(original), ", ".join(sorted(set(out))) or "-")
|
|
1590
|
+
return original
|
|
1591
|
+
|
|
1592
|
+
# Cheapest first. SECURE before COMMUNITY at equal price: Community publishes no uptime
|
|
1593
|
+
# SLA, which is the existing preference in `gpu_fallbacks` and not ours to reverse here.
|
|
1594
|
+
in_stock.sort(key=lambda c: (price[str(c[0])], 0 if str(c[1]).upper() == "SECURE" else 1))
|
|
1595
|
+
if out:
|
|
1596
|
+
log.info("availability: %d candidate(s) have no stock (%s)", len(out),
|
|
1597
|
+
", ".join(sorted(set(out))))
|
|
1598
|
+
log.info("availability: trying %s first at $%.2f/hr",
|
|
1599
|
+
in_stock[0][0], price[str(in_stock[0][0])])
|
|
1600
|
+
return in_stock
|
|
1601
|
+
|
|
1602
|
+
async def diag_create(self, spec: PodSpec, gpu_type_id: str = "",
|
|
1603
|
+
cloud_type: str = "") -> dict:
|
|
1604
|
+
"""Create ONE pod and DO NOT verify or reap it — the owner reads its System Logs in the
|
|
1605
|
+
RunPod console, which is the ONLY place boot failures are visible (the API/SDK exposes no
|
|
1606
|
+
pod logs; open feature request). The pod is named outside the pool pattern so no reconcile
|
|
1607
|
+
or teardown can ever touch it: it lives until the owner (or an explicit diag terminate)
|
|
1608
|
+
ends it.
|
|
1609
|
+
|
|
1610
|
+
IT WALKS THE SPEC'S OWN FALLBACKS, like the real create path does. It used to hardcode
|
|
1611
|
+
`NVIDIA A40` + `SECURE` and ignore `spec.gpu_fallbacks` entirely, so on 2026-09-27 a
|
|
1612
|
+
diagnostic wake returned `{"pod_id": null}` against RunPod's "no longer any instances
|
|
1613
|
+
available with the requested specifications" — while the spec it was handed listed FOUR
|
|
1614
|
+
GPU types across two cloud types, seven of which were never tried. A diagnostic that fails
|
|
1615
|
+
where production would have succeeded is worse than no diagnostic: it reads as "the node
|
|
1616
|
+
cannot start" and sends you hunting the node.
|
|
1617
|
+
|
|
1618
|
+
`model_fit_error` is applied for the same reason the real path applies it: a model that
|
|
1619
|
+
cannot fit the card boots, bills, and "loads" forever, which looks exactly like a provider
|
|
1620
|
+
fault. Refusing before money is spent is the point.
|
|
1621
|
+
"""
|
|
1622
|
+
# An explicit override still wins — that is how a caller pins one combination on purpose.
|
|
1623
|
+
if gpu_type_id:
|
|
1624
|
+
fallbacks = ((gpu_type_id, cloud_type or "SECURE", ""),)
|
|
1625
|
+
else:
|
|
1626
|
+
fallbacks = tuple(spec.gpu_fallbacks) or (("NVIDIA A40", "SECURE", ""),)
|
|
1627
|
+
|
|
1628
|
+
# INSIDE A CREATION GUARD, AND THIS ONE IS PAID FOR. This path used to create pods with
|
|
1629
|
+
# nothing holding them, and the warning it logged said so. On 2026-09-27 the caller's HTTP
|
|
1630
|
+
# request hit Cloud Run's 300s cap mid-walk, the task was cancelled, and a pod appeared
|
|
1631
|
+
# AFTER the caller had already reported "no pod created" and run its own account check —
|
|
1632
|
+
# so it billed at $0.79/hr, unseen, until a manual account read found it.
|
|
1633
|
+
#
|
|
1634
|
+
# The guard reaps in a `finally`, so an exception, a timeout or a CANCELLED TASK cannot
|
|
1635
|
+
# skip it, and when a create was ambiguous it re-lists until pods stop appearing — which is
|
|
1636
|
+
# exactly the late-arriving pod that got away. `owns` is the diag pod's own name, so this
|
|
1637
|
+
# can never touch a pool pod.
|
|
1638
|
+
#
|
|
1639
|
+
# A DIAG POD MUST SURVIVE its own guard: it exists so the owner can read boot failures in
|
|
1640
|
+
# the provider console, which is the only place they are visible. `keep()` is what spares
|
|
1641
|
+
# it, and it is called ONLY for the pod actually created.
|
|
1642
|
+
model = str(spec.env.get("STEVAI_OLLAMA_MODEL", "") or "")
|
|
1643
|
+
# Same live-stock ordering as the real wake path. A diagnostic that searches differently
|
|
1644
|
+
# from production is a diagnostic that lies about production.
|
|
1645
|
+
if not gpu_type_id:
|
|
1646
|
+
fallbacks = await self.rank_by_availability(fallbacks, model=model,
|
|
1647
|
+
disk_gb=spec.container_disk_gb)
|
|
1648
|
+
refused: list = []
|
|
1649
|
+
async with self.creation_guard(_pool_scope(spec.name)) as tracker:
|
|
1650
|
+
for entry in fallbacks:
|
|
1651
|
+
gt, ct = entry[0], entry[1]
|
|
1652
|
+
dc = entry[2] if len(entry) > 2 else ""
|
|
1653
|
+
if model:
|
|
1654
|
+
err = model_fit_error(model, gt, spec.gpu_count, spec.container_disk_gb)
|
|
1655
|
+
if err:
|
|
1656
|
+
log.error("diag REFUSING gpu=%s cloud=%s: %s", gt, ct, err)
|
|
1657
|
+
refused.append({"gpu": gt, "cloud": ct, "reason": err})
|
|
1658
|
+
continue
|
|
1659
|
+
pod_id = await self.create_pod(spec, gt, ct, data_center_id=dc)
|
|
1660
|
+
if pod_id:
|
|
1661
|
+
tracker.keep(pod_id) # the ONLY thing that spares it from the reap
|
|
1662
|
+
info = await self.diag_info(pod_id)
|
|
1663
|
+
return {"pod_id": pod_id, "gpu": gt, "cloud": ct,
|
|
1664
|
+
**({"refused": refused} if refused else {}), **info}
|
|
1665
|
+
# SAY WHICH. "no capacity" is a claim about the MARKET; a rejected request is a
|
|
1666
|
+
# claim about US, and they send a reader to opposite places. Measured 2026-09-29:
|
|
1667
|
+
# nineteen creates were reported as capacity refusals while RunPod was actually
|
|
1668
|
+
# answering `ValueError: No GPU found with the specified ID` — the caller had
|
|
1669
|
+
# passed a DISPLAY name (`A40`) where the API wants a type id (`NVIDIA A40`). Two
|
|
1670
|
+
# hours went into the account balance, datacenter pinning and the stock table
|
|
1671
|
+
# because this line named the wrong subsystem with confidence.
|
|
1672
|
+
refused.append({"gpu": gt, "cloud": ct,
|
|
1673
|
+
"reason": _LAST_CREATE_ERROR.get(gt) or "no capacity or create refused"})
|
|
1674
|
+
# Every combination refused. Say WHICH, so the next attempt is informed rather than a
|
|
1675
|
+
# repeat — a silent None here once cost three armed windows spent guessing. Nothing was
|
|
1676
|
+
# kept, so the guard's reap on the way out cleans up any ambiguous create.
|
|
1677
|
+
log.error("diag create exhausted %d combination(s) for %s", len(fallbacks), spec.name)
|
|
1678
|
+
return {"pod_id": None, "tried": len(fallbacks), "refused": refused}
|
|
1679
|
+
|
|
1680
|
+
async def diag_info(self, pod_id: str) -> dict:
|
|
1681
|
+
"""Everything observable about one pod, including the datacenter (which the SDK's own pod
|
|
1682
|
+
query does not select — this is a door-internal GraphQL query, allowed here and only
|
|
1683
|
+
here)."""
|
|
1684
|
+
pod = await self.get_pod(pod_id) or {}
|
|
1685
|
+
out = {
|
|
1686
|
+
"desiredStatus": pod.get("desiredStatus"),
|
|
1687
|
+
"costPerHr": pod.get("costPerHr"),
|
|
1688
|
+
"runtime": pod.get("runtime"),
|
|
1689
|
+
"machine": pod.get("machine"),
|
|
1690
|
+
"lastStatusChange": pod.get("lastStatusChange"),
|
|
1691
|
+
}
|
|
1692
|
+
try:
|
|
1693
|
+
q = ('query($id:String!){pod(input:{podId:$id}){machineId machine{dataCenterId '
|
|
1694
|
+
'gpuDisplayName}}}')
|
|
1695
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
1696
|
+
r = await c.post(self._GQL, json={"query": q, "variables": {"id": pod_id}},
|
|
1697
|
+
headers={"Authorization": f"Bearer {self._key()}",
|
|
1698
|
+
"content-type": "application/json"})
|
|
1699
|
+
d = ((r.json().get("data") or {}).get("pod")) or {}
|
|
1700
|
+
out["machineId"] = d.get("machineId")
|
|
1701
|
+
out["dataCenter"] = (d.get("machine") or {}).get("dataCenterId")
|
|
1702
|
+
out["gpu"] = (d.get("machine") or {}).get("gpuDisplayName")
|
|
1703
|
+
except Exception as e:
|
|
1704
|
+
out["dataCenter"] = f"<query failed: {type(e).__name__}>"
|
|
1705
|
+
return out
|
|
1706
|
+
|
|
1707
|
+
# -- prices (the ONE sanctioned GraphQL call — the SDK has no price query) -----------------
|
|
1708
|
+
|
|
1709
|
+
async def gpu_prices(self) -> List[dict]:
|
|
1710
|
+
"""Live per-GPU pricing, for `gpuprices.py`. Returns [] on any failure."""
|
|
1711
|
+
if not self.configured():
|
|
1712
|
+
return []
|
|
1713
|
+
query = ("query{gpuTypes{id displayName memoryInGb securePrice communityPrice "
|
|
1714
|
+
"lowestPrice(input:{gpuCount:1}){minimumBidPrice uninterruptablePrice}}}")
|
|
1715
|
+
try:
|
|
1716
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
1717
|
+
r = await c.post(self._GQL, json={"query": query},
|
|
1718
|
+
headers={"Authorization": f"Bearer {self._key()}",
|
|
1719
|
+
"content-type": "application/json"})
|
|
1720
|
+
if r.status_code >= 300:
|
|
1721
|
+
log.warning("runpod price fetch HTTP %s", r.status_code)
|
|
1722
|
+
return []
|
|
1723
|
+
return ((r.json().get("data") or {}).get("gpuTypes")) or []
|
|
1724
|
+
except Exception as e:
|
|
1725
|
+
log.warning("runpod price fetch: %s", type(e).__name__)
|
|
1726
|
+
return []
|
|
1727
|
+
|
|
1728
|
+
# -- the generic provider contract (find/create/destroy for start/stop above) --------------
|
|
1729
|
+
|
|
1730
|
+
async def find(self, label: str) -> List[Instance]:
|
|
1731
|
+
return await self.find_all(label)
|
|
1732
|
+
|
|
1733
|
+
async def find_all(self, label: str) -> List[Instance]:
|
|
1734
|
+
"""TEARDOWN breadth. Everything RunPod has not TERMINATED still bills, including a pod
|
|
1735
|
+
whose `desiredStatus` is RUNNING with no runtime — the most expensive shape there is."""
|
|
1736
|
+
return [Instance(id=p.get("id"), label=p.get("name"))
|
|
1737
|
+
for p in await self.list_pods()
|
|
1738
|
+
if str(p.get("name", "")) == label
|
|
1739
|
+
and str(p.get("desiredStatus", "")).upper() != "TERMINATED"]
|
|
1740
|
+
|
|
1741
|
+
async def find_live(self, label: str) -> List[Instance]:
|
|
1742
|
+
"""CAPACITY. `list_live` keyed by name: RUNNING **and** carrying a runtime object.
|
|
1743
|
+
|
|
1744
|
+
RunPod is the provider that can answer both questions honestly, because `desiredStatus`
|
|
1745
|
+
and `runtime` are two separate facts — intent and reality.
|
|
1746
|
+
"""
|
|
1747
|
+
return [Instance(id=p.get("id"), label=p.get("name"))
|
|
1748
|
+
for p in await self.list_live() if str(p.get("name", "")) == label]
|
|
1749
|
+
|
|
1750
|
+
async def create(self, label: str, spec: dict) -> bool:
|
|
1751
|
+
pod_spec = PodSpec.from_operator_spec(spec, name=label)
|
|
1752
|
+
return bool(await self.create_ready(pod_spec))
|
|
1753
|
+
|
|
1754
|
+
async def destroy(self, instance_id: str) -> bool:
|
|
1755
|
+
return await self.terminate(instance_id)
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
# ---- Vast.ai -------------------------------------------------------------------------------
|
|
1759
|
+
|
|
1760
|
+
class VastProvider:
|
|
1761
|
+
"""Vast.ai. NOTE it spells the label `label`, not `name` — the exact kind of difference
|
|
1762
|
+
that made a hand-written start/stop pair per provider unsafe."""
|
|
1763
|
+
|
|
1764
|
+
name = "vast"
|
|
1765
|
+
_BASE = "https://console.vast.ai/api/v0"
|
|
1766
|
+
|
|
1767
|
+
def _key(self) -> str:
|
|
1768
|
+
return _provider_credential("vast", "VAST_API_KEY")
|
|
1769
|
+
|
|
1770
|
+
def configured(self) -> bool:
|
|
1771
|
+
return bool(self._key())
|
|
1772
|
+
|
|
1773
|
+
def _headers(self) -> dict:
|
|
1774
|
+
return {"Authorization": f"Bearer {self._key()}", "content-type": "application/json"}
|
|
1775
|
+
|
|
1776
|
+
#: `actual_status` values that mean the CONTAINER IS NOT SERVING. The contract, and the
|
|
1777
|
+
#: bill, outlive the container: an `exited` instance is still rented until it is destroyed.
|
|
1778
|
+
#: That is the whole 2026-09-24 lesson — "not serving" and "not billing" are not the same
|
|
1779
|
+
#: fact, and one list cannot answer both.
|
|
1780
|
+
_NOT_SERVING = frozenset({"exited"})
|
|
1781
|
+
|
|
1782
|
+
async def _labelled(self, label: str) -> List[dict]:
|
|
1783
|
+
"""Every instance on the account carrying this label, in WHATEVER state.
|
|
1784
|
+
|
|
1785
|
+
Raw and unfiltered on purpose. Which states count is the CALLER's question; a
|
|
1786
|
+
pre-filtered list is exactly what hid a paid-for corpse from both readers at once.
|
|
1787
|
+
Instances Vast has actually destroyed stop appearing here at all, so "listed" already
|
|
1788
|
+
means "still contracted".
|
|
1789
|
+
"""
|
|
1790
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
1791
|
+
r = await c.get(f"{self._BASE}/instances/", headers=self._headers(),
|
|
1792
|
+
params={"owner": "me"})
|
|
1793
|
+
if r.status_code >= 300:
|
|
1794
|
+
return []
|
|
1795
|
+
return [i for i in ((r.json() or {}).get("instances") or [])
|
|
1796
|
+
if i.get("label") == label]
|
|
1797
|
+
|
|
1798
|
+
async def find(self, label: str) -> List[Instance]:
|
|
1799
|
+
return await self.find_live(label)
|
|
1800
|
+
|
|
1801
|
+
async def find_live(self, label: str) -> List[Instance]:
|
|
1802
|
+
"""CAPACITY. Excludes `exited` — an exited instance serves nothing, and counting it
|
|
1803
|
+
would let it hold the pool's only slot and block its own replacement."""
|
|
1804
|
+
return [Instance(id=i.get("id"), label=i.get("label"))
|
|
1805
|
+
for i in await self._labelled(label)
|
|
1806
|
+
if str(i.get("actual_status", "")).lower() not in self._NOT_SERVING]
|
|
1807
|
+
|
|
1808
|
+
async def find_all(self, label: str) -> List[Instance]:
|
|
1809
|
+
"""TEARDOWN breadth. EVERY listed instance under this label, exited ones included.
|
|
1810
|
+
|
|
1811
|
+
This is the method whose absence cost money: `create` took the contract, the container
|
|
1812
|
+
exited, and the only enumeration Vast had filtered the result out of the census and the
|
|
1813
|
+
teardown together. Nothing here is a guess — an instance Vast still lists is an instance
|
|
1814
|
+
Vast is still charging for.
|
|
1815
|
+
"""
|
|
1816
|
+
return [Instance(id=i.get("id"), label=i.get("label"))
|
|
1817
|
+
for i in await self._labelled(label)]
|
|
1818
|
+
|
|
1819
|
+
async def _cheapest_offer(self, spec: dict) -> Optional[int]:
|
|
1820
|
+
"""Vast is a MARKETPLACE: you rent a specific offer, so one must be chosen first."""
|
|
1821
|
+
query = {
|
|
1822
|
+
"limit": 20, "type": "ondemand",
|
|
1823
|
+
"rentable": {"eq": True}, "verified": {"eq": True},
|
|
1824
|
+
"num_gpus": {"gte": int(spec.get("gpuCount", 1) or 1)},
|
|
1825
|
+
"reliability": {"gte": 0.95},
|
|
1826
|
+
}
|
|
1827
|
+
if spec.get("minVramGb"):
|
|
1828
|
+
query["gpu_ram"] = {"gte": int(spec["minVramGb"]) * 1024}
|
|
1829
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
1830
|
+
# TRAILING SLASH, as the API reference writes it (`POST .../api/v0/bundles/`, verified
|
|
1831
|
+
# 2026-10-12). Vast's other paths are spelled with one here too (`/asks/{id}/`,
|
|
1832
|
+
# `/instances/{id}/`); this one was not, and a missing slash on that API redirects or
|
|
1833
|
+
# 404s — which `_cheapest_offer` would report as "no offers" and the race would read as
|
|
1834
|
+
# "no capacity", not as a broken URL.
|
|
1835
|
+
r = await c.post(f"{self._BASE}/bundles/", headers=self._headers(), json=query)
|
|
1836
|
+
if r.status_code >= 300:
|
|
1837
|
+
return None
|
|
1838
|
+
offers = (r.json() or {}).get("offers") or []
|
|
1839
|
+
if not offers:
|
|
1840
|
+
return None
|
|
1841
|
+
offers.sort(key=lambda o: float(o.get("dph_total") or 1e9)) # cheapest that fits
|
|
1842
|
+
return offers[0].get("id")
|
|
1843
|
+
|
|
1844
|
+
async def create(self, label: str, spec: dict) -> bool:
|
|
1845
|
+
offer = await self._cheapest_offer(spec)
|
|
1846
|
+
if offer is None:
|
|
1847
|
+
return False
|
|
1848
|
+
body = {
|
|
1849
|
+
"image": spec.get("imageName") or spec.get("image") or "",
|
|
1850
|
+
"label": label, # the caller's label, never the spec's
|
|
1851
|
+
"disk": float(spec.get("containerDiskInGb") or 40),
|
|
1852
|
+
"env": spec.get("env") or {},
|
|
1853
|
+
"onstart": spec.get("dockerArgs") or "",
|
|
1854
|
+
"runtype": "args",
|
|
1855
|
+
}
|
|
1856
|
+
async with httpx.AsyncClient(timeout=60) as c:
|
|
1857
|
+
r = await c.put(f"{self._BASE}/asks/{offer}/", headers=self._headers(), json=body)
|
|
1858
|
+
if r.status_code >= 300:
|
|
1859
|
+
return False
|
|
1860
|
+
return bool((r.json() or {}).get("new_contract") or (r.json() or {}).get("success"))
|
|
1861
|
+
|
|
1862
|
+
async def destroy(self, instance_id: str) -> bool:
|
|
1863
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
1864
|
+
r = await c.delete(f"{self._BASE}/instances/{instance_id}/", headers=self._headers())
|
|
1865
|
+
return r.status_code < 300 or r.status_code == 404
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
# ---- Verda, ex-DataCrunch (the Phase-1 EU-sovereign provider) ------------------------------
|
|
1869
|
+
#
|
|
1870
|
+
# Finland-HQ, EU data residency, self-serve. Owner decision 2026-10-16 promotes this and Nebius
|
|
1871
|
+
# to REPLACE RunPod, so the EU pool is exactly as real as this class is.
|
|
1872
|
+
#
|
|
1873
|
+
# THE PROVIDER ID STAYS `datacrunch`. The company renamed itself to Verda in Nov 2025, but the id
|
|
1874
|
+
# is a PRIMARY KEY: it is the seed row's `id`, the key in `PROVIDERS`, the key in `_FETCHERS`, the
|
|
1875
|
+
# `providerId` on every credential row already in the database, and the prefix
|
|
1876
|
+
# `regionmap.provider_of_node` attributes a running node by. Renaming the id would orphan the
|
|
1877
|
+
# stored credential and silently un-attribute every live EU node — a rename of a key is a
|
|
1878
|
+
# migration, not a tidy-up. The class docstring, the log lines and the operator-facing text say
|
|
1879
|
+
# Verda; the id says `datacrunch`, deliberately.
|
|
1880
|
+
#
|
|
1881
|
+
# WHAT IS DIFFERENT HERE, AND WHY IT IS WRITTEN OUT
|
|
1882
|
+
# * AUTH IS OAuth2 CLIENT-CREDENTIALS, not a bearer key. Every other provider in this file
|
|
1883
|
+
# holds a static key; this one exchanges an id+secret for a short-lived access token. So the
|
|
1884
|
+
# token is CACHED — minting one per find/create/destroy would triple every call's latency and
|
|
1885
|
+
# hammer the auth endpoint during a wake storm.
|
|
1886
|
+
# * THE LABEL IS THE `hostname`. Vast spells it `label`, RunPod spells it `name`, this spells it
|
|
1887
|
+
# `hostname` — the precise class of difference that made a hand-rolled start/stop pair per
|
|
1888
|
+
# provider unsafe, which is why they all sit behind one interface.
|
|
1889
|
+
# * DELETE IS A `PUT` with `{"action": "delete"}`, not a DELETE verb.
|
|
1890
|
+
#
|
|
1891
|
+
# RE-VERIFIED 2026-10-16 against the vendor's OWN SOURCE — the official `verda-cloud/sdk-python`
|
|
1892
|
+
# package and the docs changelog — not against memory. Four things had moved since the 2026-09-07
|
|
1893
|
+
# transcription, and two of them cost money:
|
|
1894
|
+
#
|
|
1895
|
+
# 1. THE API HOST IS `api.verda.com` NOW. The SDK's `VerdaClient` default moved there. The old
|
|
1896
|
+
# `api.datacrunch.io` host still answers, so this was not yet an outage — it is a host that
|
|
1897
|
+
# is one vendor decision away from being one, and the default below follows the vendor.
|
|
1898
|
+
#
|
|
1899
|
+
# 2. `location_code` IS REQUIRED SINCE 2026-03-23, and omitting it is a 400 rather than the old
|
|
1900
|
+
# silent default to FIN-01. The previous `create` sent `"location_code": ""` whenever the
|
|
1901
|
+
# spec did not carry one — which is WORSE than omitting it, and meant every create without
|
|
1902
|
+
# an explicit location had been failing with a 400 since March. `create` now REFUSES without
|
|
1903
|
+
# a location instead of sending an empty string, for the same reason it already refuses
|
|
1904
|
+
# without an instance type: an under-specified create that 400s is a launch that silently
|
|
1905
|
+
# never happens, and `start()` reads the False as "no capacity".
|
|
1906
|
+
#
|
|
1907
|
+
# 3. A DELETE THAT NAMES NO VOLUMES LEAVES THE OS VOLUME BILLING. The vendor's own words: "By
|
|
1908
|
+
# default, no storage is selected for deletion." So the previous `destroy` reported a
|
|
1909
|
+
# successful teardown and left a paid NVMe volume behind every single time. `destroy` now
|
|
1910
|
+
# reads the instance's `os_volume_id` first and passes it with `delete_permanently`, which
|
|
1911
|
+
# is the same leak — and the same fix — as `DeleteWithInstance=false` on Alibaba. TERMINATE
|
|
1912
|
+
# MEANS NOTHING IS LEFT SPENDING, not "the compute stopped".
|
|
1913
|
+
#
|
|
1914
|
+
# 4. THE STATUS VOCABULARY IS NOW PUBLISHED, so this class can finally answer the two questions
|
|
1915
|
+
# separately — see `DISTINGUISHES_SERVING` below. `offline` BILLS AND DOES NOT SERVE, which
|
|
1916
|
+
# is precisely the state the old presence-only answer could not express.
|
|
1917
|
+
#
|
|
1918
|
+
# EU-ONLY IS NARROWER THAN "VERDA" (owner constraint, this lane). Verda's locations are FIN-01/02/03
|
|
1919
|
+
# and ICE-01, and ICE-01 is in ICELAND — EEA, not EU. An EU-residency promise served out of
|
|
1920
|
+
# Reykjavik is a promise this product did not make, so `EU_LOCATIONS` holds the Finnish codes only
|
|
1921
|
+
# and `create` refuses a location outside it. That refusal is in the adapter and not only in the
|
|
1922
|
+
# registry row because the row says which REGION the provider serves and the location code is a
|
|
1923
|
+
# finer thing the row cannot see.
|
|
1924
|
+
|
|
1925
|
+
#: Verda datacenter codes this product treats as EU. FIN-* only: `ICE-01` is Iceland, which is EEA
|
|
1926
|
+
#: but NOT an EU member state, and `regionmap.residency_providers` promises EU residency off this
|
|
1927
|
+
#: provider's `hq_country: FI`. Read from the vendor's `Locations` constants 2026-10-16.
|
|
1928
|
+
#:
|
|
1929
|
+
#: OPERATOR-OVERRIDABLE, because a new Finnish or EU datacenter must not need a release — but the
|
|
1930
|
+
#: DEFAULT is the conservative set, so a code the vendor adds later is refused until someone says
|
|
1931
|
+
#: it is in the EU rather than admitted because it was new.
|
|
1932
|
+
#: The DEFAULT set, as a string. The live answer comes from `verda_eu_locations()`.
|
|
1933
|
+
_VERDA_EU_LOCATIONS_DEFAULT = "FIN-01,FIN-02,FIN-03"
|
|
1934
|
+
|
|
1935
|
+
|
|
1936
|
+
def verda_eu_locations() -> frozenset:
|
|
1937
|
+
"""The EU location codes, READ PER CALL.
|
|
1938
|
+
|
|
1939
|
+
A FUNCTION RATHER THAN A MODULE CONSTANT, and the reason is worth recording because the
|
|
1940
|
+
alternative cost half an hour. A constant is frozen at import, so the only way to test the
|
|
1941
|
+
operator override is `importlib.reload(gpuproviders)` — and a reload REBINDS this module's
|
|
1942
|
+
`PROVIDERS` dict while every module that did `from .gpuproviders import PROVIDERS` keeps the
|
|
1943
|
+
old one. `podreaper` does exactly that, so two tests of mine reloading this module desynchronised
|
|
1944
|
+
the reaper's registry from the real one and `test_pod_reaper` started asking a REAL RunPod
|
|
1945
|
+
client for the account — an AuthenticationError 90 test-files later, in a different file,
|
|
1946
|
+
reported as somebody else's flake.
|
|
1947
|
+
|
|
1948
|
+
It is also the better shape on its own terms: `region_providers()` and `warmpool_providers()`
|
|
1949
|
+
are call-time readers for the stated reason that "an operator can change the preference order
|
|
1950
|
+
with a restart rather than a rebuild", and a datacenter allow-list is the same kind of thing.
|
|
1951
|
+
"""
|
|
1952
|
+
raw = envvar("VERDA_EU_LOCATIONS", _VERDA_EU_LOCATIONS_DEFAULT)
|
|
1953
|
+
return frozenset(c.strip().upper() for c in raw.split(",") if c.strip())
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def verda_api_base() -> str:
|
|
1957
|
+
"""The Verda API host, read per call, new vendor name first.
|
|
1958
|
+
|
|
1959
|
+
Same reasoning as `verda_eu_locations()`: testable without a module reload. `_BASE` on the
|
|
1960
|
+
class is initialised from this and remains the attribute `gpuprices` and the auth probe read,
|
|
1961
|
+
so nothing else changes.
|
|
1962
|
+
"""
|
|
1963
|
+
return (os.environ.get("VERDA_API_BASE")
|
|
1964
|
+
or os.environ.get("DATACRUNCH_API_BASE")
|
|
1965
|
+
or "https://api.verda.com/v1").rstrip("/")
|
|
1966
|
+
|
|
1967
|
+
#: The scope the token endpoint ISSUES for a Public-API client-credentials token (docs changelog
|
|
1968
|
+
#: 2026-03-20). It is NOT sent on the request — the vendor's own SDK sends no `scope` parameter,
|
|
1969
|
+
#: and adding one would be inventing a protocol. It is checked on the RESPONSE: a token minted with
|
|
1970
|
+
#: some other scope will 403 with "Insufficient scope" on the first real call, and finding that out
|
|
1971
|
+
#: at the auth step with a named reason beats finding it out at create time as an opaque refusal.
|
|
1972
|
+
VERDA_EXPECTED_SCOPE = "cloud-api-v1"
|
|
1973
|
+
|
|
1974
|
+
|
|
1975
|
+
class DataCrunchProvider:
|
|
1976
|
+
"""Verda (ex-DataCrunch). OAuth2 client-credentials; instances keyed by `hostname`.
|
|
1977
|
+
|
|
1978
|
+
`name` is `datacrunch` on purpose — see the id note above this class.
|
|
1979
|
+
"""
|
|
1980
|
+
|
|
1981
|
+
name = "datacrunch"
|
|
1982
|
+
#: Default moved to `api.verda.com` 2026-10-16, following the vendor's own SDK default.
|
|
1983
|
+
#: `DATACRUNCH_API_BASE` still overrides it, and `VERDA_API_BASE` is accepted too so an
|
|
1984
|
+
#: operator reaching for the new name is not silently ignored.
|
|
1985
|
+
_BASE = verda_api_base()
|
|
1986
|
+
|
|
1987
|
+
#: IT DISTINGUISHES NOW, AND THAT IS A READING RATHER THAN A DECISION TO BE BRAVER.
|
|
1988
|
+
#:
|
|
1989
|
+
#: This was the ONE provider declaring False, and its own comment said what it would take to
|
|
1990
|
+
#: change: "Classifying it would need a verified status list from the vendor, which is a ruling
|
|
1991
|
+
#: and an API check, not a guess made here." That list is now published — `verda/constants.py`
|
|
1992
|
+
#: in the vendor's official SDK enumerates every `InstanceStatus` — and the vendor's docs say
|
|
1993
|
+
#: which of them bill: "Shutdown instances continue to charge your account."
|
|
1994
|
+
#:
|
|
1995
|
+
#: So the two questions have different answers here and the class can give them:
|
|
1996
|
+
#: SERVING -> `running` only (`_SERVING`)
|
|
1997
|
+
#: BILLING -> everything that is not gone (`find`, minus `_DEAD`)
|
|
1998
|
+
#: and `offline` — which bills and serves nothing — is the state that used to be reported as a
|
|
1999
|
+
#: clean "present, therefore live" to all three readers at once.
|
|
2000
|
+
#:
|
|
2001
|
+
#: `test_gpuproviders.py` enforces the declaration against the methods, so this True is only
|
|
2002
|
+
#: legal while both enumerations below exist.
|
|
2003
|
+
DISTINGUISHES_SERVING = True
|
|
2004
|
+
|
|
2005
|
+
#: Statuses that mean the instance is GONE. Everything else — including `offline` — counts as
|
|
2006
|
+
#: live for TEARDOWN. That direction is deliberate on both paths: an instance we cannot
|
|
2007
|
+
#: classify must not cause `start()` to create a second one, and must not be skipped by
|
|
2008
|
+
#: `stop()`. Guessing "probably dead" is how something keeps billing after a teardown reported
|
|
2009
|
+
#: success.
|
|
2010
|
+
#:
|
|
2011
|
+
#: `hibernating`/`starting_hibernation`/`restoring` are NOT here even though the vendor has
|
|
2012
|
+
#: REMOVED hibernation as a feature ("it was the same as Delete while keeping all storage").
|
|
2013
|
+
#: A state the vendor retired is exactly the state an old instance could still be sitting in,
|
|
2014
|
+
#: and treating a retired state as "gone" would make such an instance invisible to teardown
|
|
2015
|
+
#: while its storage bills. Retired by the vendor is not the same as absent from the account.
|
|
2016
|
+
_DEAD = frozenset({"deleted", "terminated", "destroyed", "discontinued"})
|
|
2017
|
+
|
|
2018
|
+
#: The ONLY status that means this instance is actually SERVING. A deliberately narrow set:
|
|
2019
|
+
#: `provisioning` and `ordered` are not serving yet, `error` is not serving at all, and
|
|
2020
|
+
#: `offline` is a shut-down instance that still charges the account. Anything the vendor adds
|
|
2021
|
+
#: later is NOT serving until someone adds it here, which is the safe direction for a capacity
|
|
2022
|
+
#: answer — an unknown status holds no pool slot and so cannot block its own replacement, while
|
|
2023
|
+
#: `find`/`find_all` still see it and still tear it down.
|
|
2024
|
+
_SERVING = frozenset({"running"})
|
|
2025
|
+
|
|
2026
|
+
def __init__(self) -> None:
|
|
2027
|
+
self._token = ""
|
|
2028
|
+
self._token_expires = 0.0
|
|
2029
|
+
|
|
2030
|
+
def _creds(self) -> tuple:
|
|
2031
|
+
"""The client id and secret, under EITHER naming.
|
|
2032
|
+
|
|
2033
|
+
TWO NAMES, NEW ONE FIRST. The credential was stored under `DATACRUNCH_*` before the vendor
|
|
2034
|
+
renamed itself; an operator seeding it today reaches for `VERDA_*`. Which of the two is in
|
|
2035
|
+
the database is not knowable from here — so BOTH are read, the new name wins, and the old
|
|
2036
|
+
one keeps working. The alternative is a rename migration that strands a working credential
|
|
2037
|
+
and reports the provider unconfigured, which is the 2026-09-07 RunPod key failure shape:
|
|
2038
|
+
present in the registry, invisible to the code, "configured: true" in the doctor.
|
|
2039
|
+
|
|
2040
|
+
A PAIR IS NOT MIXED ACROSS NAMINGS. An id read from `VERDA_CLIENT_ID` is never paired with
|
|
2041
|
+
a secret read from `DATACRUNCH_CLIENT_SECRET`: half of a rotated pair and half of a retired
|
|
2042
|
+
one authenticate as neither, and the resulting 401 looks like a revoked key rather than a
|
|
2043
|
+
seeding mistake. So each naming is resolved whole, and the first COMPLETE pair wins.
|
|
2044
|
+
"""
|
|
2045
|
+
for cid_name, sec_name in (("VERDA_CLIENT_ID", "VERDA_CLIENT_SECRET"),
|
|
2046
|
+
("DATACRUNCH_CLIENT_ID", "DATACRUNCH_CLIENT_SECRET")):
|
|
2047
|
+
cid = _provider_credential("datacrunch", cid_name)
|
|
2048
|
+
secret = _provider_credential("datacrunch", sec_name)
|
|
2049
|
+
if cid and secret:
|
|
2050
|
+
return (cid, secret)
|
|
2051
|
+
return ("", "")
|
|
2052
|
+
|
|
2053
|
+
def credential_names(self) -> "tuple[str, str]":
|
|
2054
|
+
"""WHICH naming resolved, for the doctor and the probe. Names only, never values — the name
|
|
2055
|
+
of an env var is not secret (file 28 §O) and its contents never leave `_creds`."""
|
|
2056
|
+
for cid_name, sec_name in (("VERDA_CLIENT_ID", "VERDA_CLIENT_SECRET"),
|
|
2057
|
+
("DATACRUNCH_CLIENT_ID", "DATACRUNCH_CLIENT_SECRET")):
|
|
2058
|
+
if _provider_credential("datacrunch", cid_name) and _provider_credential("datacrunch", sec_name):
|
|
2059
|
+
return (cid_name, sec_name)
|
|
2060
|
+
return ("", "")
|
|
2061
|
+
|
|
2062
|
+
def configured(self) -> bool:
|
|
2063
|
+
cid, secret = self._creds()
|
|
2064
|
+
return bool(cid and secret)
|
|
2065
|
+
|
|
2066
|
+
async def _access_token(self) -> str:
|
|
2067
|
+
"""A cached OAuth2 access token, refreshed a minute before it actually expires.
|
|
2068
|
+
|
|
2069
|
+
The margin matters: a token that passes the check here and expires in flight produces a
|
|
2070
|
+
401 on the CREATE call, and a create that fails ambiguously is the one failure mode that
|
|
2071
|
+
can leave an instance running that nobody recorded.
|
|
2072
|
+
|
|
2073
|
+
NO `scope` IS SENT. The vendor's own SDK sends `grant_type`, `client_id` and
|
|
2074
|
+
`client_secret` and nothing else; `cloud-api-v1` is what the server PUTS IN THE RESPONSE
|
|
2075
|
+
(docs changelog 2026-03-20). The response scope is checked and a mismatch is LOGGED rather
|
|
2076
|
+
than refused — the vendor is free to rename or widen its own scope string, and refusing a
|
|
2077
|
+
token the server just issued would ground the EU pool over a label. The log is what turns
|
|
2078
|
+
the later "Insufficient scope" 403 from a mystery into a lookup.
|
|
2079
|
+
"""
|
|
2080
|
+
now = time.time()
|
|
2081
|
+
if self._token and now < self._token_expires:
|
|
2082
|
+
return self._token
|
|
2083
|
+
cid, secret = self._creds()
|
|
2084
|
+
if not (cid and secret):
|
|
2085
|
+
return ""
|
|
2086
|
+
try:
|
|
2087
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
2088
|
+
r = await c.post(f"{self._BASE}/oauth2/token",
|
|
2089
|
+
json={"grant_type": "client_credentials",
|
|
2090
|
+
"client_id": cid, "client_secret": secret})
|
|
2091
|
+
except Exception as e:
|
|
2092
|
+
# A TRANSPORT FAILURE HERE USED TO RAISE OUT OF EVERY METHOD ON THIS CLASS, which
|
|
2093
|
+
# contradicts the contract `_headers()` states one function below in as many words:
|
|
2094
|
+
# "Callers check for None and fail the way the contract says — 'nothing running' /
|
|
2095
|
+
# 'did not create' — never by raising."
|
|
2096
|
+
#
|
|
2097
|
+
# It was unwrapped from before this lane existed, and the reachable consequences are
|
|
2098
|
+
# real: `create()` has no try/except of its own, so a DNS blip or a connection reset
|
|
2099
|
+
# on the token endpoint propagated out of a provider method as an exception rather
|
|
2100
|
+
# than as "no capacity". `find()` has the same shape. Nebius's exchange was already
|
|
2101
|
+
# wrapped; this is Verda catching up.
|
|
2102
|
+
#
|
|
2103
|
+
# The TYPE only — never the exception's text. A client error can quote the request,
|
|
2104
|
+
# and this request carries the client secret.
|
|
2105
|
+
log.warning("verda: token endpoint unreachable (%s) — treating as no token",
|
|
2106
|
+
type(e).__name__)
|
|
2107
|
+
return ""
|
|
2108
|
+
if r.status_code >= 300:
|
|
2109
|
+
log.warning("verda: token endpoint refused the client credentials (HTTP %s)",
|
|
2110
|
+
r.status_code)
|
|
2111
|
+
return ""
|
|
2112
|
+
data = r.json() or {}
|
|
2113
|
+
token = str(data.get("access_token") or "")
|
|
2114
|
+
if not token:
|
|
2115
|
+
return ""
|
|
2116
|
+
scope = str(data.get("scope") or "")
|
|
2117
|
+
if scope and VERDA_EXPECTED_SCOPE not in scope.split():
|
|
2118
|
+
log.warning("verda: token issued with scope %r, expected %r — Public API calls may "
|
|
2119
|
+
"return 403 'Insufficient scope'", scope, VERDA_EXPECTED_SCOPE)
|
|
2120
|
+
self._token = token
|
|
2121
|
+
self._token_expires = now + max(0.0, float(data.get("expires_in") or 3600)) - 60
|
|
2122
|
+
return token
|
|
2123
|
+
|
|
2124
|
+
async def _headers(self) -> "Optional[dict]":
|
|
2125
|
+
"""Auth headers, or None when no token could be obtained.
|
|
2126
|
+
|
|
2127
|
+
None rather than an empty bearer: `Authorization: Bearer ` is an ILLEGAL header value and
|
|
2128
|
+
httpx raises `LocalProtocolError` on it, so a simple credential problem would surface as
|
|
2129
|
+
a protocol crash from inside the transport. Callers check for None and fail the way the
|
|
2130
|
+
contract says — "nothing running" / "did not create" — never by raising.
|
|
2131
|
+
"""
|
|
2132
|
+
token = await self._access_token()
|
|
2133
|
+
if not token:
|
|
2134
|
+
return None
|
|
2135
|
+
return {"Authorization": f"Bearer {token}", "content-type": "application/json"}
|
|
2136
|
+
|
|
2137
|
+
async def _instances(self) -> "Optional[List[dict]]":
|
|
2138
|
+
"""Every instance on the account, or None when the account could not be ASKED.
|
|
2139
|
+
|
|
2140
|
+
None IS NOT AN EMPTY LIST, and that distinction is this codebase's most expensive recurring
|
|
2141
|
+
bug: an unreadable list read as "nothing is there". `find` converts None to `[]` because
|
|
2142
|
+
its contract says so, but `destroy` needs the difference — it must not conclude an instance
|
|
2143
|
+
has no OS volume because the list call failed.
|
|
2144
|
+
"""
|
|
2145
|
+
headers = await self._headers()
|
|
2146
|
+
if headers is None:
|
|
2147
|
+
return None
|
|
2148
|
+
try:
|
|
2149
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
2150
|
+
r = await c.get(f"{self._BASE}/instances", headers=headers)
|
|
2151
|
+
except Exception as e:
|
|
2152
|
+
log.warning("verda: could not list instances (%s)", type(e).__name__)
|
|
2153
|
+
return None
|
|
2154
|
+
if r.status_code >= 300:
|
|
2155
|
+
log.warning("verda: instance list HTTP %s", r.status_code)
|
|
2156
|
+
return None
|
|
2157
|
+
body = r.json() or []
|
|
2158
|
+
rows = body if isinstance(body, list) else (body.get("instances") or body.get("data") or [])
|
|
2159
|
+
return [i for i in rows if isinstance(i, dict)]
|
|
2160
|
+
|
|
2161
|
+
def _matching(self, rows: "List[dict]", label: str, statuses: "Optional[frozenset]" = None,
|
|
2162
|
+
*, exclude_dead: bool = True) -> List[Instance]:
|
|
2163
|
+
"""The rows carrying `label`, narrowed to `statuses` when one is given."""
|
|
2164
|
+
out: List[Instance] = []
|
|
2165
|
+
for i in rows:
|
|
2166
|
+
if i.get("hostname") != label:
|
|
2167
|
+
continue
|
|
2168
|
+
status = str(i.get("status", "")).lower()
|
|
2169
|
+
if exclude_dead and status in self._DEAD:
|
|
2170
|
+
continue
|
|
2171
|
+
if statuses is not None and status not in statuses:
|
|
2172
|
+
continue
|
|
2173
|
+
out.append(Instance(id=i.get("id"), label=i.get("hostname"), status=status))
|
|
2174
|
+
return out
|
|
2175
|
+
|
|
2176
|
+
async def find(self, label: str) -> List[Instance]:
|
|
2177
|
+
"""THE BASE PRIMITIVE: anything carrying this label that is not GONE.
|
|
2178
|
+
|
|
2179
|
+
What `start()`'s idempotency check wants — "is anything of mine already here" — answered as
|
|
2180
|
+
conservatively as the vendor's vocabulary allows.
|
|
2181
|
+
"""
|
|
2182
|
+
rows = await self._instances()
|
|
2183
|
+
return self._matching(rows or [], label)
|
|
2184
|
+
|
|
2185
|
+
async def find_live(self, label: str) -> List[Instance]:
|
|
2186
|
+
"""CAPACITY: only instances that are actually SERVING (`running`).
|
|
2187
|
+
|
|
2188
|
+
This is the half the presence-only answer got wrong in the expensive direction. An
|
|
2189
|
+
`offline` instance was reported live, so it held its pool slot, no replacement was ever
|
|
2190
|
+
created, and the account was charged for a machine serving nothing.
|
|
2191
|
+
"""
|
|
2192
|
+
rows = await self._instances()
|
|
2193
|
+
return self._matching(rows or [], label, self._SERVING)
|
|
2194
|
+
|
|
2195
|
+
async def find_all(self, label: str) -> List[Instance]:
|
|
2196
|
+
"""TEARDOWN: everything under this label that could still BILL — `offline` included.
|
|
2197
|
+
|
|
2198
|
+
Identical to `find` today, and NOT an alias for it: they answer different questions and
|
|
2199
|
+
the vendor could add a status that is billing-but-not-live-for-idempotency. Writing it out
|
|
2200
|
+
keeps `DISTINGUISHES_SERVING = True` honest and keeps the two call sites independent.
|
|
2201
|
+
"""
|
|
2202
|
+
rows = await self._instances()
|
|
2203
|
+
return self._matching(rows or [], label)
|
|
2204
|
+
|
|
2205
|
+
async def status(self, label: str) -> dict:
|
|
2206
|
+
"""The vendor's own status for each instance under this label, unflattened.
|
|
2207
|
+
|
|
2208
|
+
For operators and for the live-proof log: `find*` return the classification and this
|
|
2209
|
+
returns what was classified. A teardown that has to be defended later needs the raw word
|
|
2210
|
+
the vendor used, not our bucket for it.
|
|
2211
|
+
|
|
2212
|
+
`{"ok": False}` when the account could not be asked — never an empty `instances` list,
|
|
2213
|
+
which would read as "the label is clear".
|
|
2214
|
+
"""
|
|
2215
|
+
rows = await self._instances()
|
|
2216
|
+
if rows is None:
|
|
2217
|
+
return {"ok": False, "provider": self.name, "label": label,
|
|
2218
|
+
"detail": "could not list instances — this is NOT 'the label is clear'"}
|
|
2219
|
+
mine = [i for i in rows if i.get("hostname") == label]
|
|
2220
|
+
return {
|
|
2221
|
+
"ok": True,
|
|
2222
|
+
"provider": self.name,
|
|
2223
|
+
"label": label,
|
|
2224
|
+
"instances": [{"id": i.get("id"), "status": str(i.get("status", "")).lower(),
|
|
2225
|
+
"location": i.get("location") or i.get("location_code") or "",
|
|
2226
|
+
"os_volume_id": i.get("os_volume_id") or "",
|
|
2227
|
+
"price_per_hour": i.get("price_per_hour"),
|
|
2228
|
+
"serving": str(i.get("status", "")).lower() in self._SERVING,
|
|
2229
|
+
"billing": str(i.get("status", "")).lower() not in self._DEAD}
|
|
2230
|
+
for i in mine],
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
async def list_account(self) -> "Optional[List[dict]]":
|
|
2234
|
+
"""EVERYTHING on the account, label-agnostic, normalised. `None` when it could not be asked.
|
|
2235
|
+
|
|
2236
|
+
THE QUESTION `find(label)` CANNOT ANSWER, and the reason `podreaper` exists at all: the
|
|
2237
|
+
guard in this module only covers instances created inside a guarded window, and the
|
|
2238
|
+
2026-09-17 orphan was created by a process that had already exited believing it had failed.
|
|
2239
|
+
Something has to be able to ask "what is actually on this account" with no memory of what
|
|
2240
|
+
was supposed to be there — and that question has no label to filter on.
|
|
2241
|
+
|
|
2242
|
+
`None` rather than `[]` on failure, loudly, because `podreaper`'s whole discipline is that
|
|
2243
|
+
a list it could not read is NOT "the account is clean".
|
|
2244
|
+
"""
|
|
2245
|
+
rows = await self._instances()
|
|
2246
|
+
if rows is None:
|
|
2247
|
+
return None
|
|
2248
|
+
return [{"id": str(i.get("id") or ""),
|
|
2249
|
+
"label": str(i.get("hostname") or ""),
|
|
2250
|
+
"status": str(i.get("status", "")).lower(),
|
|
2251
|
+
"created_at": i.get("created_at") or "",
|
|
2252
|
+
"price_per_hour": i.get("price_per_hour"),
|
|
2253
|
+
"location": i.get("location") or i.get("location_code") or "",
|
|
2254
|
+
"billing": str(i.get("status", "")).lower() not in self._DEAD}
|
|
2255
|
+
for i in rows]
|
|
2256
|
+
|
|
2257
|
+
async def list_volumes(self) -> "Optional[List[dict]]":
|
|
2258
|
+
"""Every non-deleted volume on the account, normalised. `None` when it could not be asked.
|
|
2259
|
+
|
|
2260
|
+
WHY THIS EXISTS, AND IT IS A BILL THAT IS ALREADY RUNNING. Until 2026-10-16 `destroy` did
|
|
2261
|
+
not name the volume ids, and the vendor deletes no storage unless the delete names it ("by
|
|
2262
|
+
default, no storage is selected for deletion"). So every Verda instance this platform has
|
|
2263
|
+
ever torn down very likely left a detached, paid NVMe volume behind. Closing the leak does
|
|
2264
|
+
not reclaim the residue, and nothing else in this repo can see it: `podreaper --providers`
|
|
2265
|
+
enumerates INSTANCES, and `frutebox/volumes.py` is a cost model, not an inventory.
|
|
2266
|
+
|
|
2267
|
+
IT READS AND NOTHING ELSE. There is no delete anywhere on this path, deliberately — a
|
|
2268
|
+
detached volume may be somebody's deliberately-kept dataset, and this code cannot tell that
|
|
2269
|
+
from an orphan. `test_verda_orphan_volumes.py` asserts the absence over the syntax tree.
|
|
2270
|
+
|
|
2271
|
+
NO `?status=detached`, THOUGH THE VENDOR OFFERS IT. The filter would make "0 detached" and
|
|
2272
|
+
"the call did not work" the same output, which is the mistake this codebase keeps paying
|
|
2273
|
+
for. Everything is fetched and classified here, so a zero always arrives with the total it
|
|
2274
|
+
was drawn from — and a volume whose `status` the vendor words differently than we expect is
|
|
2275
|
+
still seen, instead of being filtered out of existence upstream.
|
|
2276
|
+
"""
|
|
2277
|
+
headers = await self._headers()
|
|
2278
|
+
if headers is None:
|
|
2279
|
+
return None
|
|
2280
|
+
try:
|
|
2281
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
2282
|
+
r = await c.get(f"{self._BASE}/volumes", headers=headers)
|
|
2283
|
+
except Exception as e:
|
|
2284
|
+
log.warning("verda: could not list volumes (%s)", type(e).__name__)
|
|
2285
|
+
return None
|
|
2286
|
+
if r.status_code >= 300:
|
|
2287
|
+
log.warning("verda: volume list HTTP %s", r.status_code)
|
|
2288
|
+
return None
|
|
2289
|
+
body = r.json() or []
|
|
2290
|
+
rows = body if isinstance(body, list) else (body.get("volumes") or body.get("data") or [])
|
|
2291
|
+
out: List[dict] = []
|
|
2292
|
+
for v in rows:
|
|
2293
|
+
if not isinstance(v, dict):
|
|
2294
|
+
continue
|
|
2295
|
+
out.append({
|
|
2296
|
+
"id": str(v.get("id") or ""),
|
|
2297
|
+
"name": str(v.get("name") or ""),
|
|
2298
|
+
"status": str(v.get("status", "")).lower(),
|
|
2299
|
+
"type": str(v.get("type") or ""),
|
|
2300
|
+
"size_gb": v.get("size"),
|
|
2301
|
+
"is_os_volume": bool(v.get("is_os_volume")),
|
|
2302
|
+
# None when detached, per the vendor's own field documentation.
|
|
2303
|
+
"instance_id": v.get("instance_id") or None,
|
|
2304
|
+
"location": str(v.get("location") or v.get("location_code") or "").upper(),
|
|
2305
|
+
"created_at": v.get("created_at") or "",
|
|
2306
|
+
"deleted_at": v.get("deleted_at") or None,
|
|
2307
|
+
# THE NUMBERS THAT MAKE THIS WORTH READING. The vendor returns them per volume, so
|
|
2308
|
+
# the report can say what the residue COSTS rather than how many rows it has.
|
|
2309
|
+
"hourly_cost": v.get("base_hourly_cost"),
|
|
2310
|
+
"monthly_price": v.get("monthly_price"),
|
|
2311
|
+
"currency": str(v.get("currency") or ""),
|
|
2312
|
+
})
|
|
2313
|
+
return out
|
|
2314
|
+
|
|
2315
|
+
async def create(self, label: str, spec: dict) -> bool:
|
|
2316
|
+
location = str(spec.get("locationCode") or spec.get("location_code") or "").strip().upper()
|
|
2317
|
+
body = {
|
|
2318
|
+
# The CALLER's label, never the spec's. A provider that lets the spec's name through
|
|
2319
|
+
# is the 2026-08-21 billing bug reintroduced: the wake cannot find what it started.
|
|
2320
|
+
"hostname": label,
|
|
2321
|
+
"description": spec.get("description") or label,
|
|
2322
|
+
"instance_type": spec.get("instanceType") or spec.get("instance_type") or "",
|
|
2323
|
+
"image": spec.get("image") or spec.get("imageName") or "",
|
|
2324
|
+
"location_code": location,
|
|
2325
|
+
}
|
|
2326
|
+
if not body["instance_type"] or not body["image"]:
|
|
2327
|
+
return False # an under-specified create would 400 anyway; fail honestly
|
|
2328
|
+
if not location:
|
|
2329
|
+
# REQUIRED SINCE 2026-03-23 — omitted is a 400, and the empty string this used to send
|
|
2330
|
+
# is worse than omitted. Refusing here names the missing field; sending "" produced an
|
|
2331
|
+
# opaque 400 that `start()` read as "no capacity in the EU".
|
|
2332
|
+
log.error("verda: refusing to create %r without a location_code. It has been REQUIRED "
|
|
2333
|
+
"since 2026-03-23 (an absent one is HTTP 400, not a default to FIN-01). Set "
|
|
2334
|
+
"it in the launch spec; EU codes are %s",
|
|
2335
|
+
label, ",".join(sorted(verda_eu_locations())))
|
|
2336
|
+
return False
|
|
2337
|
+
if location not in verda_eu_locations():
|
|
2338
|
+
# ICE-01 is Iceland: EEA, not EU. See `verda_eu_locations`.
|
|
2339
|
+
log.error("verda: refusing to create %r in %s — not an EU location. Permitted: %s",
|
|
2340
|
+
label, location, ",".join(sorted(verda_eu_locations())))
|
|
2341
|
+
return False
|
|
2342
|
+
ssh = spec.get("sshKeyIds") or spec.get("ssh_key_ids")
|
|
2343
|
+
if ssh:
|
|
2344
|
+
body["ssh_key_ids"] = list(ssh)
|
|
2345
|
+
script = spec.get("startupScriptId") or spec.get("startup_script_id")
|
|
2346
|
+
if script:
|
|
2347
|
+
body["startup_script_id"] = script
|
|
2348
|
+
for optional in ("contract", "is_spot", "os_volume", "volumes", "existing_volumes",
|
|
2349
|
+
"pricing"):
|
|
2350
|
+
if spec.get(optional) is not None:
|
|
2351
|
+
body[optional] = spec[optional]
|
|
2352
|
+
headers = await self._headers()
|
|
2353
|
+
if headers is None:
|
|
2354
|
+
return False
|
|
2355
|
+
async with httpx.AsyncClient(timeout=90) as c:
|
|
2356
|
+
r = await c.post(f"{self._BASE}/instances", headers=headers, json=body)
|
|
2357
|
+
if r.status_code >= 300:
|
|
2358
|
+
log.error("verda: create %r HTTP %s", label, r.status_code)
|
|
2359
|
+
return False
|
|
2360
|
+
return True
|
|
2361
|
+
|
|
2362
|
+
async def destroy(self, instance_id: str) -> bool:
|
|
2363
|
+
"""Terminate — AND take the OS volume with it. 404 counts as success.
|
|
2364
|
+
|
|
2365
|
+
THE VOLUME IS THE POINT OF THIS METHOD'S SHAPE. The vendor: "By default, no storage is
|
|
2366
|
+
selected for deletion." So the obvious `{"action": "delete", "id": X}` deletes the compute,
|
|
2367
|
+
reports 200, and leaves a paid NVMe volume on the account forever — a teardown that
|
|
2368
|
+
reports success and keeps spending, which is the exact failure `destroy_verified` exists to
|
|
2369
|
+
catch and which no amount of checking the HTTP status would have revealed.
|
|
2370
|
+
|
|
2371
|
+
So this reads the instance's `os_volume_id` first and names it, with `delete_permanently`.
|
|
2372
|
+
|
|
2373
|
+
A FAILED LOOKUP DOES NOT BECOME "NO VOLUME". If the account cannot be listed, the delete
|
|
2374
|
+
still goes ahead — leaving a running GPU to bill because a list call failed would be the
|
|
2375
|
+
worse trade — but it is LOGGED as a teardown whose storage was not accounted for, so the
|
|
2376
|
+
residue is discoverable instead of silent.
|
|
2377
|
+
|
|
2378
|
+
THERE IS NO VOLUME SWEEP TO FALL BACK ON, and this docstring said there was. `volumes.py`
|
|
2379
|
+
is PURE POLICY — "should a network volume exist, given cold-start frequency and cost" —
|
|
2380
|
+
with no I/O and no CLI at all, so `python -m frutebox.volumes --sweep` was a command that
|
|
2381
|
+
does not exist, pointed at an operator in a log line they would only ever read while
|
|
2382
|
+
chasing a bill. Checked, not assumed; see the WARNING text below for what it says now.
|
|
2383
|
+
"""
|
|
2384
|
+
headers = await self._headers()
|
|
2385
|
+
if headers is None:
|
|
2386
|
+
return False
|
|
2387
|
+
|
|
2388
|
+
volume_ids: List[str] = []
|
|
2389
|
+
rows = await self._instances()
|
|
2390
|
+
if rows is None:
|
|
2391
|
+
log.warning("verda: could not read instance %s before deleting it — its OS volume is "
|
|
2392
|
+
"NOT accounted for and may keep billing. Nothing in this repo lists Verda "
|
|
2393
|
+
"volumes; check `GET %s/volumes` with the same credential, or the "
|
|
2394
|
+
"Storage page in the Verda console, and delete any detached volume whose "
|
|
2395
|
+
"instance is gone.", instance_id, self._BASE)
|
|
2396
|
+
else:
|
|
2397
|
+
for i in rows:
|
|
2398
|
+
if str(i.get("id")) != str(instance_id):
|
|
2399
|
+
continue
|
|
2400
|
+
if i.get("os_volume_id"):
|
|
2401
|
+
volume_ids.append(str(i["os_volume_id"]))
|
|
2402
|
+
for v in (i.get("volumes") or []):
|
|
2403
|
+
vid = v.get("id") if isinstance(v, dict) else v
|
|
2404
|
+
if vid:
|
|
2405
|
+
volume_ids.append(str(vid))
|
|
2406
|
+
break
|
|
2407
|
+
|
|
2408
|
+
body: Dict[str, object] = {"action": "delete", "id": instance_id}
|
|
2409
|
+
if volume_ids:
|
|
2410
|
+
# Both keys together: `delete_permanently` is documented as applying only when
|
|
2411
|
+
# `volume_ids` is also present, so sending it alone would be a no-op that reads like a
|
|
2412
|
+
# guarantee.
|
|
2413
|
+
body["volume_ids"] = volume_ids
|
|
2414
|
+
body["delete_permanently"] = True
|
|
2415
|
+
async with httpx.AsyncClient(timeout=60) as c:
|
|
2416
|
+
r = await c.put(f"{self._BASE}/instances", headers=headers, json=body)
|
|
2417
|
+
return r.status_code < 300 or r.status_code == 404
|
|
2418
|
+
|
|
2419
|
+
|
|
2420
|
+
#: Volume statuses that mean the volume is GONE and cannot be billing. Everything else counts as
|
|
2421
|
+
#: present — including a status we have not seen, for the same reason `_DEAD` is a small set on the
|
|
2422
|
+
#: instance side: an unclassifiable volume must be REPORTED, never filtered into non-existence.
|
|
2423
|
+
VERDA_VOLUME_DEAD = frozenset({"deleted", "deleting"})
|
|
2424
|
+
|
|
2425
|
+
|
|
2426
|
+
def verda_detached_volumes(rows: "List[dict]") -> "List[dict]":
|
|
2427
|
+
"""The volumes nobody is holding: present, and attached to no instance.
|
|
2428
|
+
|
|
2429
|
+
DETACHED IS READ FROM `instance_id`, NOT FROM `status`, AND BOTH ARE CONSULTED. The vendor
|
|
2430
|
+
documents `instance_id` as "None if detached", which is the fact we actually care about —
|
|
2431
|
+
whether anything is using it — while `status` is a lifecycle word whose vocabulary we have only
|
|
2432
|
+
partially verified. A volume with no instance is detached whatever its status says, and a
|
|
2433
|
+
volume the vendor explicitly calls `detached` is detached even if some future response carries
|
|
2434
|
+
a stale instance id. Either signal is enough; neither is required to agree with the other.
|
|
2435
|
+
|
|
2436
|
+
THIS IS NOT "ORPHANED". A detached volume may be a dataset somebody keeps on purpose, and
|
|
2437
|
+
nothing here can tell that from residue — which is exactly why the caller only ever LISTS.
|
|
2438
|
+
"""
|
|
2439
|
+
out = []
|
|
2440
|
+
for v in rows:
|
|
2441
|
+
if str(v.get("status", "")).lower() in VERDA_VOLUME_DEAD or v.get("deleted_at"):
|
|
2442
|
+
continue
|
|
2443
|
+
if v.get("instance_id") and str(v.get("status", "")).lower() != "detached":
|
|
2444
|
+
continue
|
|
2445
|
+
out.append(v)
|
|
2446
|
+
return out
|
|
2447
|
+
|
|
2448
|
+
|
|
2449
|
+
# ---- POD SPEC DERIVATION: the cheapest EU single-GPU box that can run the proof ------------
|
|
2450
|
+
#
|
|
2451
|
+
# SUPERVISOR DECISION 2026-10-17: "POD_SPECs are not an owner decision — set the defaults to the
|
|
2452
|
+
# cheapest EU single-GPU type in each provider's LIVE catalog that fits the proof model; log the
|
|
2453
|
+
# choice + price."
|
|
2454
|
+
#
|
|
2455
|
+
# WHY THIS IS A DERIVATION AND NOT A DEFAULT STRING, which is the one design call in here. The
|
|
2456
|
+
# instruction says "in each provider's LIVE catalog", and a catalog is not readable without a
|
|
2457
|
+
# credential — Owners 33/34 are open, so no catalog has been read on this machine. Writing
|
|
2458
|
+
# `1V100.6V` into a constant today would therefore be a GUESS wearing the costume of a default:
|
|
2459
|
+
# the 2026-09-24 `VAST_POD_SPEC` ruling is that a provider which guesses the spec deploys the
|
|
2460
|
+
# wrong machine and bills for it, and a guess that happens to be in a registry file is no better
|
|
2461
|
+
# than one in an adapter.
|
|
2462
|
+
#
|
|
2463
|
+
# So the DEFAULT IS THE DERIVATION. `FRUTEBOX_<NAME>_POD_SPEC` still wins when an operator sets it
|
|
2464
|
+
# — that is what makes a deliberate choice possible — and when it is unset these functions read
|
|
2465
|
+
# the vendor's live catalog and choose. The consequence is the property the instruction actually
|
|
2466
|
+
# wants: the moment a credential resolves, the cheapest qualifying EU box is picked with no edit
|
|
2467
|
+
# and no redeploy, and the choice and its price are LOGGED rather than assumed.
|
|
2468
|
+
#
|
|
2469
|
+
# WHAT "FITS THE PROOF MODEL" MEANS, in numbers rather than adjectives. The live proof boots our
|
|
2470
|
+
# node image and serves `gemma3:4b` through ollama. That is a ~4B-parameter model at q4 — roughly
|
|
2471
|
+
# 3 GB of weights, plus a KV cache and ollama's overhead — so the floor below is generous on
|
|
2472
|
+
# purpose: every current datacenter GPU clears it, which keeps this a FILTER rather than a
|
|
2473
|
+
# constraint that quietly excludes the cheap end of the catalog and overspends.
|
|
2474
|
+
#
|
|
2475
|
+
# SINGLE GPU IS A HARD FILTER, NOT A PREFERENCE. A multi-GPU box can be cheaper per card and is
|
|
2476
|
+
# never cheaper per hour, and the proof rents by the hour. `gpu_count == 1` is also what makes the
|
|
2477
|
+
# €10 cap meaningful: an 8-GPU instance at the same per-card price is eight times the bill.
|
|
2478
|
+
|
|
2479
|
+
#: VRAM floor for the live-proof model, in GB per card. Overridable because the proof model is
|
|
2480
|
+
#: itself a choice; the default is sized for `gemma3:4b` with room, not measured against it.
|
|
2481
|
+
PROOF_MIN_VRAM_GB = int(envvar("PROOF_MIN_VRAM_GB", "16"))
|
|
2482
|
+
|
|
2483
|
+
|
|
2484
|
+
def _cheapest_fitting(candidates: "List[dict]") -> "Optional[dict]":
|
|
2485
|
+
"""The cheapest candidate, with ties broken by name so the choice is REPRODUCIBLE.
|
|
2486
|
+
|
|
2487
|
+
A tie broken by dict order would make the derivation pick differently on two runs against the
|
|
2488
|
+
same catalog, which turns "the choice was logged" into a claim nobody can check later.
|
|
2489
|
+
"""
|
|
2490
|
+
if not candidates:
|
|
2491
|
+
return None
|
|
2492
|
+
return sorted(candidates, key=lambda c: (round(float(c["usd_per_hr"]), 6), c["name"]))[0]
|
|
2493
|
+
|
|
2494
|
+
|
|
2495
|
+
#: Image flavours the node must NOT boot. Each one is a preinstalled stack that fights the node's
|
|
2496
|
+
#: own provisioning: `jupyter` ships a notebook server, `kubernetes` a kubelet expecting a cluster,
|
|
2497
|
+
#: `docker` a daemon we neither need nor configure. `node-startup.sh` installs ollama and the agent
|
|
2498
|
+
#: onto a bare host, so a plain OS+CUDA image is the only one it is written for.
|
|
2499
|
+
_VERDA_IMAGE_REJECT = ("jupyter", "kubernetes", "k8s", "docker")
|
|
2500
|
+
|
|
2501
|
+
|
|
2502
|
+
def _verda_pick_image(candidates: "List[str]") -> str:
|
|
2503
|
+
"""The newest PLAIN Ubuntu+CUDA image, by parsed version rather than by string order.
|
|
2504
|
+
|
|
2505
|
+
THE BUG THIS REPLACES COST A BILLED CREATE. The previous line was `sorted(cuda)[-1]`, with the
|
|
2506
|
+
comment "newest by name; the catalog carries no explicit version order". Measured against the
|
|
2507
|
+
live catalog on 2026-10-18, that picked `jupyter.cuda.12.8` out of a list whose real newest was
|
|
2508
|
+
`26.04.cuda13.4` — because `'j'` sorts after `'2'`. So "newest by name" was not newest, it was
|
|
2509
|
+
last alphabetically, and it chose a notebook image for a headless node. The create then failed
|
|
2510
|
+
for an unrelated reason and hid it; it would have booted the wrong machine otherwise.
|
|
2511
|
+
|
|
2512
|
+
Versions are compared as TUPLES OF INTEGERS (`26.04` + `cuda13.4` -> `(26, 4, 13, 4)`), so
|
|
2513
|
+
`cuda13.4` beats `cuda12.9` — which string order gets wrong too, since `'2' < '3'` but
|
|
2514
|
+
`'12' > '13'` lexicographically only after the first differing digit. A name we cannot parse
|
|
2515
|
+
sorts LAST rather than being dropped: it stays selectable if nothing else qualifies, but never
|
|
2516
|
+
outranks a version we can actually read.
|
|
2517
|
+
"""
|
|
2518
|
+
import re
|
|
2519
|
+
|
|
2520
|
+
def version_key(name: str):
|
|
2521
|
+
nums = tuple(int(n) for n in re.findall(r"\d+", name))
|
|
2522
|
+
return (1, nums) if nums else (0, ())
|
|
2523
|
+
|
|
2524
|
+
plain = [c for c in candidates
|
|
2525
|
+
if not any(bad in c.lower() for bad in _VERDA_IMAGE_REJECT)]
|
|
2526
|
+
if not plain:
|
|
2527
|
+
return ""
|
|
2528
|
+
return sorted(plain, key=version_key)[-1]
|
|
2529
|
+
|
|
2530
|
+
|
|
2531
|
+
async def verda_candidates(*, min_vram_gb: "Optional[int]" = None) -> "Optional[List[dict]]":
|
|
2532
|
+
"""Every qualifying EU single-GPU offering, CHEAPEST FIRST. `None` when unaskable.
|
|
2533
|
+
|
|
2534
|
+
WHY A LIST AND NOT JUST THE WINNER. `/instance-availability` says a type is in stock, and that
|
|
2535
|
+
is NOT a promise it can still be created a second later: measured 2026-10-20, a `1L40S.20V`
|
|
2536
|
+
the catalog listed as available in FIN-02 answered `503 "No capacity available"` on create.
|
|
2537
|
+
A derivation that returns one winner leaves its caller with nothing to do about that except
|
|
2538
|
+
fail, which is how a launch reports "no EU capacity" while five other types are rentable.
|
|
2539
|
+
`nodewake._create_cheapest_first` already walks fallbacks for the fleet; this gives the live
|
|
2540
|
+
proof and any other caller the same ability.
|
|
2541
|
+
|
|
2542
|
+
ONE FILTER, SHARED. `derive_datacrunch_pod_spec` calls this and takes the head, so the two can
|
|
2543
|
+
never disagree about what "qualifying" means — a second copy of the gpu-count/VRAM/stock
|
|
2544
|
+
predicate is the drift this repo keeps paying for.
|
|
2545
|
+
"""
|
|
2546
|
+
floor = PROOF_MIN_VRAM_GB if min_vram_gb is None else int(min_vram_gb)
|
|
2547
|
+
prov = PROVIDERS.get("datacrunch")
|
|
2548
|
+
if prov is None or not prov.configured():
|
|
2549
|
+
log.error("verda: cannot read the catalog without a credential — Owner 33, not a code "
|
|
2550
|
+
"change.")
|
|
2551
|
+
return None
|
|
2552
|
+
headers = await prov._headers()
|
|
2553
|
+
if headers is None:
|
|
2554
|
+
log.error("verda: the credential was refused a token")
|
|
2555
|
+
return None
|
|
2556
|
+
try:
|
|
2557
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
2558
|
+
types_r = await c.get(f"{prov._BASE}/instance-types", headers=headers)
|
|
2559
|
+
avail_r = await c.get(f"{prov._BASE}/instance-availability", headers=headers)
|
|
2560
|
+
except Exception as e:
|
|
2561
|
+
log.error("verda: catalog read failed (%s)", type(e).__name__)
|
|
2562
|
+
return None
|
|
2563
|
+
if types_r.status_code >= 300 or avail_r.status_code >= 300:
|
|
2564
|
+
log.error("verda: catalog read HTTP %s/%s", types_r.status_code, avail_r.status_code)
|
|
2565
|
+
return None
|
|
2566
|
+
|
|
2567
|
+
eu = verda_eu_locations()
|
|
2568
|
+
stock: Dict[str, List[str]] = {}
|
|
2569
|
+
for loc in (avail_r.json() or []):
|
|
2570
|
+
if not isinstance(loc, dict):
|
|
2571
|
+
continue
|
|
2572
|
+
code = str(loc.get("location_code") or "").upper()
|
|
2573
|
+
if code not in eu:
|
|
2574
|
+
continue
|
|
2575
|
+
for t in loc.get("availabilities") or []:
|
|
2576
|
+
stock.setdefault(str(t), []).append(code)
|
|
2577
|
+
|
|
2578
|
+
out: List[dict] = []
|
|
2579
|
+
for t in (types_r.json() or []):
|
|
2580
|
+
if not isinstance(t, dict):
|
|
2581
|
+
continue
|
|
2582
|
+
name = str(t.get("instance_type") or "").strip()
|
|
2583
|
+
if not name or name not in stock:
|
|
2584
|
+
continue
|
|
2585
|
+
gpu = t.get("gpu") or {}
|
|
2586
|
+
if int(gpu.get("number_of_gpus") or 0) != 1:
|
|
2587
|
+
continue
|
|
2588
|
+
vram = int((t.get("gpu_memory") or {}).get("size_in_gigabytes") or 0)
|
|
2589
|
+
if vram < floor:
|
|
2590
|
+
continue
|
|
2591
|
+
try:
|
|
2592
|
+
price = float(t.get("price_per_hour") or 0)
|
|
2593
|
+
except (TypeError, ValueError):
|
|
2594
|
+
continue
|
|
2595
|
+
if price <= 0:
|
|
2596
|
+
continue
|
|
2597
|
+
# EVERY EU LOCATION WITH STOCK, not just the first: a 503 is often per-location, so the
|
|
2598
|
+
# same type elsewhere is the cheapest retry available.
|
|
2599
|
+
for loc_code in sorted(stock[name]):
|
|
2600
|
+
out.append({"name": name, "usd_per_hr": price, "vram_gb": vram,
|
|
2601
|
+
"location": loc_code,
|
|
2602
|
+
"currency": str(t.get("currency") or "").lower(),
|
|
2603
|
+
"gpu": str(gpu.get("description") or "")})
|
|
2604
|
+
out.sort(key=lambda c: (round(c["usd_per_hr"], 6), c["name"], c["location"]))
|
|
2605
|
+
return out
|
|
2606
|
+
|
|
2607
|
+
|
|
2608
|
+
async def derive_datacrunch_pod_spec(*, min_vram_gb: "Optional[int]" = None) -> "Optional[dict]":
|
|
2609
|
+
"""Verda's cheapest in-stock EU single-GPU instance type, as a complete launch spec.
|
|
2610
|
+
|
|
2611
|
+
THE HEAD OF `verda_candidates()`, plus the image and SSH keys a create also needs. The filter
|
|
2612
|
+
lives in exactly one place: this function used to carry its own copy of the
|
|
2613
|
+
gpu-count/VRAM/stock predicate, and its docstring claimed the refactor that removed it before
|
|
2614
|
+
the refactor existed — `test_no_orphan_implementations` caught the lie by noticing
|
|
2615
|
+
`verda_candidates` had no caller.
|
|
2616
|
+
|
|
2617
|
+
`None` on any failure, with the reason LOGGED — never a partial spec. A spec missing its image
|
|
2618
|
+
or its location is a create that 400s, and `start()` reads that as "no EU capacity".
|
|
2619
|
+
"""
|
|
2620
|
+
floor = PROOF_MIN_VRAM_GB if min_vram_gb is None else int(min_vram_gb)
|
|
2621
|
+
prov = PROVIDERS.get("datacrunch")
|
|
2622
|
+
cands = await verda_candidates(min_vram_gb=floor)
|
|
2623
|
+
if cands is None:
|
|
2624
|
+
return None
|
|
2625
|
+
best = cands[0] if cands else None
|
|
2626
|
+
if best is None:
|
|
2627
|
+
log.error("verda: no in-stock EU single-GPU type with >=%dGB VRAM in the catalog. NOT "
|
|
2628
|
+
"falling back to a guess.", floor)
|
|
2629
|
+
return None
|
|
2630
|
+
headers = await prov._headers()
|
|
2631
|
+
if headers is None:
|
|
2632
|
+
return None
|
|
2633
|
+
|
|
2634
|
+
# The image is a second catalog read, scoped to the chosen type so the answer is one the
|
|
2635
|
+
# vendor says can actually boot on it.
|
|
2636
|
+
try:
|
|
2637
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
2638
|
+
img_r = await c.get(f"{prov._BASE}/images", headers=headers,
|
|
2639
|
+
params={"instance_type": best["name"]})
|
|
2640
|
+
images = (img_r.json() or []) if img_r.status_code < 300 else []
|
|
2641
|
+
except Exception as e:
|
|
2642
|
+
log.error("verda: image catalog read failed (%s)", type(e).__name__)
|
|
2643
|
+
return None
|
|
2644
|
+
cuda = [str(i.get("image_type") or "") for i in images
|
|
2645
|
+
if isinstance(i, dict) and "cuda" in str(i.get("image_type") or "").lower()]
|
|
2646
|
+
if not cuda:
|
|
2647
|
+
log.error("verda: no CUDA image offered for %s (%d image(s) listed) — refusing to guess "
|
|
2648
|
+
"an image name, which would boot a machine with no driver and bill for it",
|
|
2649
|
+
best["name"], len(images))
|
|
2650
|
+
return None
|
|
2651
|
+
image = _verda_pick_image(cuda)
|
|
2652
|
+
if not image:
|
|
2653
|
+
log.error("verda: %d CUDA image(s) offered for %s but none is a PLAIN OS image (every one "
|
|
2654
|
+
"is a jupyter/kubernetes/docker variant) — refusing to pick one, because the "
|
|
2655
|
+
"node needs a bare host it can install onto", len(cuda), best["name"])
|
|
2656
|
+
return None
|
|
2657
|
+
|
|
2658
|
+
# SSH KEYS ARE NOT OPTIONAL HERE, and the vendor's first error message said otherwise.
|
|
2659
|
+
# `POST /instances` without them answers 400 "SSH keys can be an array of UUID's, a single
|
|
2660
|
+
# UUID string, null value or not defined" — which lists `not defined` as acceptable and is
|
|
2661
|
+
# misleading: sending null and sending nothing both fail, and the SECOND message is the true
|
|
2662
|
+
# one, "SSH keys must be provided if image is not an OS volume". Measured 2026-10-18.
|
|
2663
|
+
#
|
|
2664
|
+
# DISCOVERED, NOT CREATED. A derivation reads the account; minting a key is a write and a
|
|
2665
|
+
# credential, which is the live proof's job (it makes an ephemeral one and removes it). If the
|
|
2666
|
+
# account has none, this says so rather than returning a spec whose create cannot succeed.
|
|
2667
|
+
ssh_ids = await _verda_ssh_key_ids(prov, headers)
|
|
2668
|
+
if ssh_ids is None:
|
|
2669
|
+
log.error("verda: could not read the account's SSH keys — not deriving a spec, because a "
|
|
2670
|
+
"create without them is a guaranteed 400")
|
|
2671
|
+
return None
|
|
2672
|
+
if not ssh_ids:
|
|
2673
|
+
log.error("verda: the account has NO SSH keys, and Verda refuses an image boot without one "
|
|
2674
|
+
"(\"SSH keys must be provided if image is not an OS volume\"). Add one, or let "
|
|
2675
|
+
"the live proof mint an ephemeral key. Not returning a spec that cannot launch.")
|
|
2676
|
+
return None
|
|
2677
|
+
|
|
2678
|
+
# `sym` and the candidate count come from the chosen row and the list it was chosen from —
|
|
2679
|
+
# both now owned by `verda_candidates`, which is the point of the refactor.
|
|
2680
|
+
sym = {"eur": "EUR", "usd": "USD"}.get(best["currency"], best["currency"].upper() or "?")
|
|
2681
|
+
log.info("verda: derived pod spec — instance_type=%s gpu=%s vram=%dGB location=%s "
|
|
2682
|
+
"image=%s PRICE=%.4f %s/hr (cheapest in-stock EU single-GPU over %dGB, chosen from "
|
|
2683
|
+
"%d qualifying offering(s))",
|
|
2684
|
+
best["name"], best["gpu"], best["vram_gb"], best["location"], image,
|
|
2685
|
+
best["usd_per_hr"], sym, floor, len(cands))
|
|
2686
|
+
return {
|
|
2687
|
+
"instance_type": best["name"],
|
|
2688
|
+
"image": image,
|
|
2689
|
+
"location_code": best["location"],
|
|
2690
|
+
"ssh_key_ids": ssh_ids,
|
|
2691
|
+
"_derived": {"price_per_hr": best["usd_per_hr"], "currency": sym,
|
|
2692
|
+
"vram_gb": best["vram_gb"], "gpu": best["gpu"],
|
|
2693
|
+
"qualifying": len(cands),
|
|
2694
|
+
"source": "verda /instance-types + /instance-availability + /images"},
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
|
|
2698
|
+
async def _verda_ssh_key_ids(prov, headers: dict) -> "Optional[List[str]]":
|
|
2699
|
+
"""The account's SSH key ids, or `None` when the account could not be ASKED.
|
|
2700
|
+
|
|
2701
|
+
`/ssh-keys` is the CURRENT path; `/sshkeys` is a deprecated alias the vendor's own SDK still
|
|
2702
|
+
uses (docs changelog 2026-09-21). The current one is used here, and the deprecated one is not
|
|
2703
|
+
tried as a fallback: if the current path stops answering, that is news rather than something to
|
|
2704
|
+
paper over.
|
|
2705
|
+
|
|
2706
|
+
`None` is not `[]`. An unreadable key list means "do not launch"; an empty one means "the
|
|
2707
|
+
account genuinely has no key", and the two lead to different messages and different fixes.
|
|
2708
|
+
"""
|
|
2709
|
+
try:
|
|
2710
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
2711
|
+
r = await c.get(f"{prov._BASE}/ssh-keys", headers=headers)
|
|
2712
|
+
except Exception as e:
|
|
2713
|
+
log.warning("verda: could not list SSH keys (%s)", type(e).__name__)
|
|
2714
|
+
return None
|
|
2715
|
+
if r.status_code >= 300:
|
|
2716
|
+
log.warning("verda: SSH key list HTTP %s", r.status_code)
|
|
2717
|
+
return None
|
|
2718
|
+
rows = r.json() or []
|
|
2719
|
+
return [str(k.get("id")) for k in rows if isinstance(k, dict) and k.get("id")]
|
|
2720
|
+
|
|
2721
|
+
|
|
2722
|
+
async def derive_nebius_pod_spec(*, min_vram_gb: "Optional[int]" = None) -> "Optional[dict]":
|
|
2723
|
+
"""Nebius's cheapest EU single-GPU preset, as a complete launch spec.
|
|
2724
|
+
|
|
2725
|
+
WHERE "CHEAPEST" COMES FROM, after one correction. `/compute/v1/platforms` publishes
|
|
2726
|
+
platforms, presets and GPU counts and NO money, and on that basis I previously wrote that the
|
|
2727
|
+
vendor publishes no price and required the operator's `FRUTEBOX_NEBIUS_PRICES` table. That was
|
|
2728
|
+
wrong: Nebius publishes no price LIST, but
|
|
2729
|
+
`/billing/v1alpha1/calculators/estimate` prices a SPECIFIC platform+preset, which is exactly
|
|
2730
|
+
the question here. So the order is now the operator's table FIRST (it is what the account is
|
|
2731
|
+
contracted at) and the vendor's calculator second (list price for the configuration), and the
|
|
2732
|
+
chosen row records WHICH answered — reporting one as the other would be a false provenance on
|
|
2733
|
+
a figure that gates spending.
|
|
2734
|
+
|
|
2735
|
+
With neither source answering, "cheapest" is unanswerable and this says so instead of picking
|
|
2736
|
+
— except where exactly one preset fits at all, when there is nothing to be cheapest among.
|
|
2737
|
+
|
|
2738
|
+
The subnet and the image are DISCOVERED (§A5), not configured: a guessed subnet is a machine on
|
|
2739
|
+
the wrong network or an unreachable one, and a guessed image family is a box with no driver.
|
|
2740
|
+
"""
|
|
2741
|
+
floor = PROOF_MIN_VRAM_GB if min_vram_gb is None else int(min_vram_gb)
|
|
2742
|
+
prov = PROVIDERS.get("nebius")
|
|
2743
|
+
if prov is None or not prov.configured():
|
|
2744
|
+
log.error("nebius: cannot derive a pod spec without an authorized key and a project — "
|
|
2745
|
+
"the catalog is an authenticated read. This is Owner 34, not a code change.")
|
|
2746
|
+
return None
|
|
2747
|
+
project = await prov._resolve_project()
|
|
2748
|
+
if not project:
|
|
2749
|
+
log.error("nebius: no project for region %s, so there is no catalog to derive from",
|
|
2750
|
+
NEBIUS_REGION)
|
|
2751
|
+
return None
|
|
2752
|
+
if not await prov._assert_eu_project(project):
|
|
2753
|
+
# The EU gate fails CLOSED here exactly as it does in `create`: an unverified jurisdiction
|
|
2754
|
+
# is not an EU one, and deriving a spec for a non-EU project would hand `create` something
|
|
2755
|
+
# it must then refuse.
|
|
2756
|
+
return None
|
|
2757
|
+
headers = await prov._headers()
|
|
2758
|
+
if headers is None:
|
|
2759
|
+
return None
|
|
2760
|
+
|
|
2761
|
+
try:
|
|
2762
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
2763
|
+
plat_r = await c.get(f"{prov._BASE}/compute/v1/platforms", headers=headers,
|
|
2764
|
+
params={"parentId": project})
|
|
2765
|
+
subnet_r = await c.get(f"{prov._BASE}/vpc/v1/subnets", headers=headers,
|
|
2766
|
+
params={"parentId": project})
|
|
2767
|
+
except Exception as e:
|
|
2768
|
+
log.error("nebius: catalog read failed (%s) — not deriving a spec", type(e).__name__)
|
|
2769
|
+
return None
|
|
2770
|
+
if plat_r.status_code >= 300 or subnet_r.status_code >= 300:
|
|
2771
|
+
log.error("nebius: catalog read HTTP %s/%s — not deriving a spec",
|
|
2772
|
+
plat_r.status_code, subnet_r.status_code)
|
|
2773
|
+
return None
|
|
2774
|
+
|
|
2775
|
+
prices = nebius_price_table()
|
|
2776
|
+
|
|
2777
|
+
fitting: List[dict] = []
|
|
2778
|
+
for plat in ((plat_r.json() or {}).get("items") or []):
|
|
2779
|
+
if not isinstance(plat, dict):
|
|
2780
|
+
continue
|
|
2781
|
+
pname = str(((plat.get("metadata") or {}).get("name") or "")).strip()
|
|
2782
|
+
pspec = plat.get("spec") or {}
|
|
2783
|
+
for preset in (pspec.get("presets") or []):
|
|
2784
|
+
res = preset.get("resources") or {}
|
|
2785
|
+
if int(res.get("gpuCount") or 0) != 1:
|
|
2786
|
+
continue
|
|
2787
|
+
# ABSENT VRAM IS UNKNOWN, NOT ZERO — and treating it as zero was my own instance of
|
|
2788
|
+
# this codebase's most expensive habit. Measured 2026-10-18: this project's catalog
|
|
2789
|
+
# publishes `gpu-b300-sxm/1gpu-24vcpu-346gb` with NO `gpuMemoryGibibytes` at either the
|
|
2790
|
+
# preset or the platform level, so `or 0` scored a B300 (288GB of HBM3e) as 0GB and the
|
|
2791
|
+
# derivation reported "no EU single-GPU preset with >=16GB VRAM" — refusing the only
|
|
2792
|
+
# qualifying GPU on the account for lack of a field.
|
|
2793
|
+
#
|
|
2794
|
+
# So an unpublished figure means the floor CANNOT BE APPLIED, and that is said out loud
|
|
2795
|
+
# rather than resolved either way silently. It is admitted because `gpuCount == 1` on a
|
|
2796
|
+
# named GPU platform is already a strong filter and the alternative discards real
|
|
2797
|
+
# capacity; the log line is what stops that being an invisible assumption.
|
|
2798
|
+
raw_vram = res.get("gpuMemoryGibibytes") or pspec.get("gpuMemoryGibibytes")
|
|
2799
|
+
vram = int(raw_vram) if raw_vram else 0
|
|
2800
|
+
if raw_vram and vram < floor:
|
|
2801
|
+
continue
|
|
2802
|
+
if not raw_vram:
|
|
2803
|
+
log.warning("nebius: %s/%s publishes no gpuMemoryGibibytes, so the >=%dGB floor "
|
|
2804
|
+
"could NOT be checked for it — admitted on gpuCount=1 alone",
|
|
2805
|
+
pname, preset.get("name"), floor)
|
|
2806
|
+
key = f"{pname}/{preset.get('name')}"
|
|
2807
|
+
fitting.append({"name": key, "platform": pname, "preset": str(preset.get("name")),
|
|
2808
|
+
"vram_gb": vram, "usd_per_hr": prices.get(key)})
|
|
2809
|
+
|
|
2810
|
+
if not fitting:
|
|
2811
|
+
log.error("nebius: no EU single-GPU preset with >=%dGB VRAM in the project's catalog. "
|
|
2812
|
+
"NOT falling back to a guess.", floor)
|
|
2813
|
+
return None
|
|
2814
|
+
|
|
2815
|
+
# THE VENDOR DOES PRICE A CONFIGURATION, and I previously wrote that it does not. What Nebius
|
|
2816
|
+
# publishes is no price LIST — `/compute/v1/platforms` carries hardware and no money — but
|
|
2817
|
+
# `/billing/v1alpha1/calculators/estimate` prices a SPECIFIC platform+preset, which is exactly
|
|
2818
|
+
# the question being asked here. Measured 2026-10-18: the 1×B300 preset returns
|
|
2819
|
+
# `hourlyCost.general.total.cost = 9.5`. An operator table is now an OVERRIDE rather than the
|
|
2820
|
+
# only source, so the cap and the merit order get a real number with no configuration at all.
|
|
2821
|
+
for f in fitting:
|
|
2822
|
+
f["price_source"] = "FRUTEBOX_NEBIUS_PRICES" if f["usd_per_hr"] is not None else ""
|
|
2823
|
+
if f["usd_per_hr"] is None:
|
|
2824
|
+
f["usd_per_hr"] = await _nebius_estimate_hourly(prov, headers, project,
|
|
2825
|
+
f["platform"], f["preset"])
|
|
2826
|
+
if f["usd_per_hr"] is not None:
|
|
2827
|
+
f["price_source"] = "vendor calculator (/billing/v1alpha1/calculators/estimate)"
|
|
2828
|
+
priced = [f for f in fitting if f["usd_per_hr"] is not None]
|
|
2829
|
+
if priced:
|
|
2830
|
+
best = _cheapest_fitting(priced)
|
|
2831
|
+
elif len(fitting) == 1:
|
|
2832
|
+
best = fitting[0]
|
|
2833
|
+
log.warning("nebius: one qualifying preset (%s), and neither the calculator nor "
|
|
2834
|
+
"FRUTEBOX_NEBIUS_PRICES gave a price — choosing it because there is nothing to "
|
|
2835
|
+
"be cheapest among, but the live-proof cap cannot be checked against it.",
|
|
2836
|
+
best["name"])
|
|
2837
|
+
else:
|
|
2838
|
+
log.error("nebius: %d qualifying presets and NO price from the calculator or "
|
|
2839
|
+
"FRUTEBOX_NEBIUS_PRICES (%s). 'Cheapest' is unanswerable and picking one would "
|
|
2840
|
+
"be spending on a coin flip.", len(fitting), ", ".join(f["name"] for f in fitting))
|
|
2841
|
+
return None
|
|
2842
|
+
|
|
2843
|
+
subnets = [s for s in ((subnet_r.json() or {}).get("items") or []) if isinstance(s, dict)]
|
|
2844
|
+
subnet_id = ""
|
|
2845
|
+
for s in subnets:
|
|
2846
|
+
sid = str(((s.get("metadata") or {}).get("id") or ""))
|
|
2847
|
+
if sid:
|
|
2848
|
+
subnet_id = sid
|
|
2849
|
+
break
|
|
2850
|
+
if not subnet_id:
|
|
2851
|
+
log.error("nebius: the project has no subnet to place an instance in (%d listed) — "
|
|
2852
|
+
"refusing to guess a subnet id", len(subnets))
|
|
2853
|
+
return None
|
|
2854
|
+
if len(subnets) > 1:
|
|
2855
|
+
log.warning("nebius: project %s has %d subnets; using %s. Set FRUTEBOX_NEBIUS_POD_SPEC if "
|
|
2856
|
+
"that is the wrong network.", project, len(subnets), subnet_id)
|
|
2857
|
+
|
|
2858
|
+
family = await _nebius_image_family(prov, headers, best["platform"], best["preset"],
|
|
2859
|
+
region=prov._project_region.get(project, ""))
|
|
2860
|
+
if not family:
|
|
2861
|
+
return None
|
|
2862
|
+
|
|
2863
|
+
# WHERE THE NUMBER CAME FROM, NAMED. It is one of two sources and they are not
|
|
2864
|
+
# interchangeable: the operator's table is what this account is contracted at, while the
|
|
2865
|
+
# vendor's calculator is list price for the configuration. Reporting one as the other is a
|
|
2866
|
+
# false provenance on a figure that gates spending — and this line did exactly that for one
|
|
2867
|
+
# commit, claiming FRUTEBOX_NEBIUS_PRICES for a number the calculator had produced.
|
|
2868
|
+
#
|
|
2869
|
+
# NEITHER CARRIES A CURRENCY. The calculator's v1alpha1 response is a bare number and the
|
|
2870
|
+
# operator's table is whatever they typed, so the unit is not asserted here. The v1 batch
|
|
2871
|
+
# endpoint does take a `currency`, and answered 500 for this spec.
|
|
2872
|
+
src_txt = best.get("price_source") or "unknown source"
|
|
2873
|
+
price_txt = (("%.4f/hr (unit unstated by the source; from %s)" % (best["usd_per_hr"], src_txt))
|
|
2874
|
+
if best["usd_per_hr"] is not None else "UNKNOWN — no price from either source")
|
|
2875
|
+
log.info("nebius: derived pod spec — platform=%s preset=%s vram=%dGB subnet=%s "
|
|
2876
|
+
"imageFamily=%s PRICE=%s (cheapest of %d qualifying EU single-GPU preset(s))",
|
|
2877
|
+
best["platform"], best["preset"], best["vram_gb"], subnet_id, family, price_txt,
|
|
2878
|
+
len(fitting))
|
|
2879
|
+
return {
|
|
2880
|
+
"platform": best["platform"],
|
|
2881
|
+
"preset": best["preset"],
|
|
2882
|
+
"imageFamily": family,
|
|
2883
|
+
"subnetId": subnet_id,
|
|
2884
|
+
"_derived": {"price_per_hr": best["usd_per_hr"],
|
|
2885
|
+
"price_source": src_txt,
|
|
2886
|
+
"currency": "unstated by the source",
|
|
2887
|
+
"vram_gb": best["vram_gb"],
|
|
2888
|
+
"vram_checked": bool(best["vram_gb"]),
|
|
2889
|
+
"qualifying": len(fitting),
|
|
2890
|
+
"source": "nebius /compute/v1/platforms + /vpc/v1/subnets"
|
|
2891
|
+
" + /compute/v1/images/list-public"},
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
|
|
2895
|
+
async def _nebius_estimate_hourly(prov, headers: dict, project: str,
|
|
2896
|
+
platform: str, preset: str) -> "Optional[float]":
|
|
2897
|
+
"""What the vendor says this platform+preset costs per hour, or `None`.
|
|
2898
|
+
|
|
2899
|
+
THE REQUEST SHAPE WAS FOUND BY ASKING, because the published schema does not give it: the
|
|
2900
|
+
OpenAPI document types `billing.v1.ResourceSpec.spec` as an unexpanded `any`, so three shapes
|
|
2901
|
+
were tried against the live API 2026-10-18 and its own errors named the problem — the first two
|
|
2902
|
+
returned "container id is required: invalid container id". The one it accepts nests the project
|
|
2903
|
+
the same way a create does, `{computeInstanceSpec: {metadata: {parentId}, spec: {resources}}}`.
|
|
2904
|
+
|
|
2905
|
+
`None` on any failure, never a zero: a zero price is the cheapest thing in any catalog and
|
|
2906
|
+
would win every comparison it entered, which is the phantom-cheapest-row failure this file
|
|
2907
|
+
guards against elsewhere.
|
|
2908
|
+
|
|
2909
|
+
THE CURRENCY IS THE ACCOUNT'S and is not asserted here. The v1alpha1 response carries a bare
|
|
2910
|
+
number with no currency field; the v1 batch endpoint takes a `currency` parameter but answered
|
|
2911
|
+
500 for this spec. So the figure is reported as the vendor's own unit rather than relabelled
|
|
2912
|
+
into euros, for the same reason Verda's price is logged as USD when the catalog says USD.
|
|
2913
|
+
"""
|
|
2914
|
+
body = {"resourceSpec": {"computeInstanceSpec": {
|
|
2915
|
+
"metadata": {"parentId": project},
|
|
2916
|
+
"spec": {"resources": {"platform": platform, "preset": preset}}}}}
|
|
2917
|
+
try:
|
|
2918
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
2919
|
+
r = await c.post(f"{prov._BASE}/billing/v1alpha1/calculators/estimate",
|
|
2920
|
+
headers=headers, json=body)
|
|
2921
|
+
except Exception as e:
|
|
2922
|
+
log.warning("nebius: price estimate for %s/%s failed (%s)", platform, preset,
|
|
2923
|
+
type(e).__name__)
|
|
2924
|
+
return None
|
|
2925
|
+
if r.status_code >= 300:
|
|
2926
|
+
log.warning("nebius: price estimate for %s/%s HTTP %s", platform, preset, r.status_code)
|
|
2927
|
+
return None
|
|
2928
|
+
try:
|
|
2929
|
+
cost = (((r.json() or {}).get("hourlyCost") or {}).get("general") or {}
|
|
2930
|
+
).get("total", {}).get("cost")
|
|
2931
|
+
return float(cost) if cost is not None else None
|
|
2932
|
+
except (TypeError, ValueError, AttributeError):
|
|
2933
|
+
log.warning("nebius: price estimate for %s/%s returned an unreadable body shape",
|
|
2934
|
+
platform, preset)
|
|
2935
|
+
return None
|
|
2936
|
+
|
|
2937
|
+
|
|
2938
|
+
async def _nebius_image_family(prov, headers: dict, platform: str, preset: str,
|
|
2939
|
+
*, region: str = "") -> str:
|
|
2940
|
+
"""A public image family the vendor says can boot on this platform/preset.
|
|
2941
|
+
|
|
2942
|
+
`ImageSpec` carries `recommendedPlatforms` and `unsupportedPresets`, so this is a READ rather
|
|
2943
|
+
than a guess: an image the vendor marks unsupported for the chosen preset is excluded, and one
|
|
2944
|
+
that recommends the chosen platform is preferred over one that merely does not forbid it.
|
|
2945
|
+
"""
|
|
2946
|
+
if not region:
|
|
2947
|
+
# The catalog cannot be read without it, and inventing one would ask a different region's
|
|
2948
|
+
# catalog than the project actually launches in.
|
|
2949
|
+
log.error("nebius: no region established for the project, so the public image catalog "
|
|
2950
|
+
"cannot be queried (it requires one) — refusing to guess an image family")
|
|
2951
|
+
return ""
|
|
2952
|
+
try:
|
|
2953
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
2954
|
+
r = await c.get(f"{prov._BASE}/compute/v1/images/list-public", headers=headers,
|
|
2955
|
+
params={"region": region})
|
|
2956
|
+
except Exception as e:
|
|
2957
|
+
log.error("nebius: public image list failed (%s)", type(e).__name__)
|
|
2958
|
+
return ""
|
|
2959
|
+
if r.status_code >= 300:
|
|
2960
|
+
log.error("nebius: public image list HTTP %s", r.status_code)
|
|
2961
|
+
return ""
|
|
2962
|
+
recommended, allowed = [], []
|
|
2963
|
+
for img in ((r.json() or {}).get("items") or []):
|
|
2964
|
+
if not isinstance(img, dict):
|
|
2965
|
+
continue
|
|
2966
|
+
spec = img.get("spec") or {}
|
|
2967
|
+
family = str(spec.get("imageFamily") or "").strip()
|
|
2968
|
+
if not family:
|
|
2969
|
+
continue
|
|
2970
|
+
if any(str(u.get("preset")) == preset and str(u.get("platform")) == platform
|
|
2971
|
+
for u in (spec.get("unsupportedPresets") or []) if isinstance(u, dict)):
|
|
2972
|
+
continue
|
|
2973
|
+
if platform in [str(p) for p in (spec.get("recommendedPlatforms") or [])]:
|
|
2974
|
+
recommended.append(family)
|
|
2975
|
+
elif platform not in [str(p) for p in (spec.get("unsupportedPlatforms") or [])]:
|
|
2976
|
+
allowed.append(family)
|
|
2977
|
+
# CUDA first within each tier: the node runs ollama on a GPU, and an image with no driver
|
|
2978
|
+
# boots a machine that bills and cannot serve.
|
|
2979
|
+
for tier in (recommended, allowed):
|
|
2980
|
+
cuda = sorted(f for f in tier if "cuda" in f.lower())
|
|
2981
|
+
if cuda:
|
|
2982
|
+
return cuda[-1]
|
|
2983
|
+
if recommended:
|
|
2984
|
+
log.warning("nebius: no CUDA image family recommended for %s; using %s, which may need a "
|
|
2985
|
+
"driver install in cloud-init", platform, sorted(recommended)[-1])
|
|
2986
|
+
return sorted(recommended)[-1]
|
|
2987
|
+
log.error("nebius: no image family the vendor permits on %s/%s — refusing to guess one",
|
|
2988
|
+
platform, preset)
|
|
2989
|
+
return ""
|
|
2990
|
+
|
|
2991
|
+
|
|
2992
|
+
# ---- Lambda Cloud --------------------------------------------------------------------------
|
|
2993
|
+
|
|
2994
|
+
class LambdaProvider:
|
|
2995
|
+
name = "lambda"
|
|
2996
|
+
_BASE = "https://cloud.lambdalabs.com/api/v1"
|
|
2997
|
+
|
|
2998
|
+
def _key(self) -> str:
|
|
2999
|
+
return _provider_credential("lambda", "LAMBDA_API_KEY")
|
|
3000
|
+
|
|
3001
|
+
def configured(self) -> bool:
|
|
3002
|
+
return bool(self._key())
|
|
3003
|
+
|
|
3004
|
+
def _headers(self) -> dict:
|
|
3005
|
+
return {"Authorization": f"Bearer {self._key()}", "content-type": "application/json"}
|
|
3006
|
+
|
|
3007
|
+
#: Gone: no longer rented, nothing left to terminate.
|
|
3008
|
+
_GONE = frozenset({"terminated"})
|
|
3009
|
+
|
|
3010
|
+
#: Rented but NOT serving. `unhealthy` is Lambda's billing corpse — the VM is up and charged
|
|
3011
|
+
#: for, and nothing on it works. `terminating` is already leaving, so it must not be counted
|
|
3012
|
+
#: as capacity a new request could land on. Both still belong to TEARDOWN.
|
|
3013
|
+
_NOT_SERVING = _GONE | frozenset({"unhealthy", "terminating"})
|
|
3014
|
+
|
|
3015
|
+
async def _labelled(self, label: str) -> List[dict]:
|
|
3016
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
3017
|
+
r = await c.get(f"{self._BASE}/instances", headers=self._headers())
|
|
3018
|
+
if r.status_code >= 300:
|
|
3019
|
+
return []
|
|
3020
|
+
return [i for i in ((r.json() or {}).get("data") or [])
|
|
3021
|
+
if i.get("name") == label]
|
|
3022
|
+
|
|
3023
|
+
async def find(self, label: str) -> List[Instance]:
|
|
3024
|
+
return await self.find_all(label)
|
|
3025
|
+
|
|
3026
|
+
async def find_all(self, label: str) -> List[Instance]:
|
|
3027
|
+
"""TEARDOWN breadth: everything Lambda has not terminated is still rented by the hour."""
|
|
3028
|
+
return [Instance(id=i.get("id"), label=i.get("name"))
|
|
3029
|
+
for i in await self._labelled(label)
|
|
3030
|
+
if str(i.get("status", "")).lower() not in self._GONE]
|
|
3031
|
+
|
|
3032
|
+
async def find_live(self, label: str) -> List[Instance]:
|
|
3033
|
+
"""CAPACITY: booting or active only.
|
|
3034
|
+
|
|
3035
|
+
Lambda rents whole VMs, so it cannot see inside the container the way RunPod's `runtime`
|
|
3036
|
+
does — an `active` VM whose workload died still reads as live here. What it CAN tell is
|
|
3037
|
+
`unhealthy`, and that one must not hold a pool slot.
|
|
3038
|
+
"""
|
|
3039
|
+
return [Instance(id=i.get("id"), label=i.get("name"))
|
|
3040
|
+
for i in await self._labelled(label)
|
|
3041
|
+
if str(i.get("status", "")).lower() not in self._NOT_SERVING]
|
|
3042
|
+
|
|
3043
|
+
async def create(self, label: str, spec: dict) -> bool:
|
|
3044
|
+
region = spec.get("region") or os.environ.get("LAMBDA_REGION", "")
|
|
3045
|
+
itype = spec.get("instanceType") or os.environ.get("LAMBDA_INSTANCE_TYPE", "")
|
|
3046
|
+
ssh = [k for k in (os.environ.get("LAMBDA_SSH_KEYS", "").split(",")) if k]
|
|
3047
|
+
|
|
3048
|
+
# §A5 option 1 (owner ruling 2026-09-22): the operator named the instance type — the
|
|
3049
|
+
# decision that costs money — so discover the account-scoped rest of it. Only what they
|
|
3050
|
+
# left unset is filled; an explicit value always wins, and nothing here picks an
|
|
3051
|
+
# instance type. Without this, an unset region silently became "us-west-1", which fails
|
|
3052
|
+
# for a type that has capacity only elsewhere.
|
|
3053
|
+
if itype and (not region or not ssh):
|
|
3054
|
+
derived = await derive_lambda_launch(instance_type=itype)
|
|
3055
|
+
region = region or derived.get("LAMBDA_REGION", "")
|
|
3056
|
+
ssh = ssh or [k for k in derived.get("LAMBDA_SSH_KEYS", "").split(",") if k]
|
|
3057
|
+
|
|
3058
|
+
body = {
|
|
3059
|
+
"region_name": region or "us-west-1",
|
|
3060
|
+
"instance_type_name": itype,
|
|
3061
|
+
"ssh_key_names": ssh,
|
|
3062
|
+
"name": label, # the caller's label, never the spec's
|
|
3063
|
+
"quantity": 1,
|
|
3064
|
+
}
|
|
3065
|
+
if not body["instance_type_name"]:
|
|
3066
|
+
return False
|
|
3067
|
+
async with httpx.AsyncClient(timeout=60) as c:
|
|
3068
|
+
r = await c.post(f"{self._BASE}/instance-operations/launch",
|
|
3069
|
+
headers=self._headers(), json=body)
|
|
3070
|
+
if r.status_code >= 300:
|
|
3071
|
+
return False
|
|
3072
|
+
return bool(((r.json() or {}).get("data") or {}).get("instance_ids"))
|
|
3073
|
+
|
|
3074
|
+
async def destroy(self, instance_id: str) -> bool:
|
|
3075
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
3076
|
+
r = await c.post(f"{self._BASE}/instance-operations/terminate",
|
|
3077
|
+
headers=self._headers(), json={"instance_ids": [instance_id]})
|
|
3078
|
+
return r.status_code < 300 or r.status_code == 404
|
|
3079
|
+
|
|
3080
|
+
|
|
3081
|
+
# ---- Nebius, the SECOND EU provider behind Verda (owner decision 7, 2026-10-14) -------------
|
|
3082
|
+
#
|
|
3083
|
+
# PROMOTED 2026-10-16: with RunPod being replaced, this and Verda ARE the EU pool.
|
|
3084
|
+
#
|
|
3085
|
+
# WHAT THIS CLASS USED TO BE, AND WHAT CHANGED. It was a scaffold that refused: `create` raised
|
|
3086
|
+
# rather than POST to a guessed path, because `api.nebius.cloud` answering 404 to an
|
|
3087
|
+
# unauthenticated root GET proves the host resolves and nothing about the operations. That refusal
|
|
3088
|
+
# was right, and the reason for it is now GONE rather than overruled — Nebius publishes an OpenAPI
|
|
3089
|
+
# document at `https://api.nebius.cloud/openapi.json` (443 paths, fetched 2026-10-16), so the paths
|
|
3090
|
+
# below were READ, not derived from the gRPC service names.
|
|
3091
|
+
#
|
|
3092
|
+
# REST, NOT THE gRPC SDK — the choice, and why it is not laziness. Every other adapter in this file
|
|
3093
|
+
# speaks HTTP through `httpx`. The `nebius` pip package is gRPC plus generated protobufs: a large
|
|
3094
|
+
# new dependency and a second concurrency model, bought to obtain paths that are now published. The
|
|
3095
|
+
# protos in `nebius/api` carry NO `google.api.http` annotations, so the OpenAPI document — not the
|
|
3096
|
+
# proto — is the authority for REST, and it is what was used. If a future operation turns out to be
|
|
3097
|
+
# gRPC-only, that is the moment to take the dependency, for that operation, with a reason.
|
|
3098
|
+
#
|
|
3099
|
+
# AUTH IS A TWO-STEP EXCHANGE, unlike every other provider here. There is no static API key: a
|
|
3100
|
+
# service account holds an AUTHORIZED KEY (a key id + the service account id + an RSA private key),
|
|
3101
|
+
# the client signs a short-lived JWT with it, and POSTs that to the IAM token endpoint to get a
|
|
3102
|
+
# 12-hour access token. Signed with `cryptography`, which is already a declared dependency —
|
|
3103
|
+
# PyJWT would be a new one for fifteen lines, and this file already has an RS256 precedent in
|
|
3104
|
+
# `tee.py`.
|
|
3105
|
+
#
|
|
3106
|
+
# THE REGION IS A PROPERTY OF THE PROJECT, NOT OF THE INSTANCE, and that is the single most
|
|
3107
|
+
# important thing about placing EU work here. `CreateInstanceRequest` has no region field at all —
|
|
3108
|
+
# `metadata.parentId` names a PROJECT, and the project's own `spec.region` decides where the
|
|
3109
|
+
# hardware is. So "EU only" cannot be enforced by inspecting a create body; it is enforced by
|
|
3110
|
+
# CHECKING THE PROJECT, which `_assert_eu_project` does against `GET /iam/v2/projects/{id}` before
|
|
3111
|
+
# anything is created. A product that promised EU residency off an unchecked `parentId` would be
|
|
3112
|
+
# promising whatever project id happened to be configured.
|
|
3113
|
+
#
|
|
3114
|
+
# RESIDENCY: NO LONGER HELD. The seed row carried `residency_hold: true` — a Dutch-incorporated
|
|
3115
|
+
# provider with a Yandex lineage, excluded from legally pinned EU work until the owner decided.
|
|
3116
|
+
# The owner decided on 2026-10-20: Nebius may run EU-locked work, so the flag is gone from the
|
|
3117
|
+
# seed row and `regionmap.residency_providers` stops excluding it. The hold was about corporate
|
|
3118
|
+
# lineage; the EU-region check above is a DIFFERENT thing and it still applies, because it is the
|
|
3119
|
+
# one that is checked rather than declared — `_assert_eu_project` reads the project's region from
|
|
3120
|
+
# IAM before any create, and that is what makes "EU only" a fact instead of a promise.
|
|
3121
|
+
#: THE EU REGION NEBIUS WORK RUNS IN (owner rule, 2026-10-21): "Point Nebius EU at the eu-north1
|
|
3122
|
+
#: project (GPUs actually start there), not eu-west2."
|
|
3123
|
+
#:
|
|
3124
|
+
#: Measured 2026-10-20, which is why it is this one: this account holds one project per region and
|
|
3125
|
+
#: their GPU CATALOGUES DIFFER. eu-west2 publishes `gpu-b300-sxm` and nothing else, so "the
|
|
3126
|
+
#: cheapest EU single-GPU preset" could only ever be a 1xB300 - and that preset reverts
|
|
3127
|
+
#: STARTING -> STOPPED after ~5m20s with no status message, no operation record and no serial
|
|
3128
|
+
#: console. eu-north1 publishes 17 GPU presets (L40S/H100/H200/GB300) and an L40S reached RUNNING
|
|
3129
|
+
#: in 78 seconds on the identical code path.
|
|
3130
|
+
NEBIUS_REGION = envvar("NEBIUS_REGION", "eu-north1")
|
|
3131
|
+
|
|
3132
|
+
NEBIUS_BASE = envvar("NEBIUS_API_BASE", "https://api.nebius.cloud").rstrip("/")
|
|
3133
|
+
|
|
3134
|
+
#: The IAM token-exchange endpoint. EU by deliberate choice of host, not only of region: the
|
|
3135
|
+
#: exchange is where the service-account assertion is presented, so sending it to a non-EU auth
|
|
3136
|
+
#: host would route the credential itself outside the bloc.
|
|
3137
|
+
NEBIUS_AUTH_URL = envvar("NEBIUS_AUTH_URL", "https://auth.eu.nebius.com/oauth2/token/exchange")
|
|
3138
|
+
|
|
3139
|
+
#: Compute REST paths, READ from the published OpenAPI document 2026-10-16 (not derived):
|
|
3140
|
+
#: POST /compute/v1/instances create
|
|
3141
|
+
#: GET /compute/v1/instances list (?parentId=&pageSize=&pageToken=)
|
|
3142
|
+
#: GET /compute/v1/instances/{id} get
|
|
3143
|
+
#: DELETE /compute/v1/instances/{id} delete
|
|
3144
|
+
#: `POST /compute/v1/instances/start` and `/stop` also exist and are NEVER called from here —
|
|
3145
|
+
#: TERMINATE NEVER STOP, and a stopped instance keeps its disks and its bill.
|
|
3146
|
+
NEBIUS_INSTANCES_PATH = "/compute/v1/instances"
|
|
3147
|
+
NEBIUS_PROJECTS_PATH = "/iam/v2/projects"
|
|
3148
|
+
|
|
3149
|
+
#: Nebius regions this product treats as EU, from the vendor's region list (2026-10-16):
|
|
3150
|
+
#: `eu-north1` Finland, `eu-west1` and `eu-west2` France.
|
|
3151
|
+
#:
|
|
3152
|
+
#: `eu-north2` IS ICELAND and is deliberately absent — EEA, not EU, exactly like Verda's ICE-01 —
|
|
3153
|
+
#: and `us-central1` is Kansas City. The `eu-` prefix is the vendor's NAME for a region, not a
|
|
3154
|
+
#: statement about EU membership, so this is an allow-list of codes and never a prefix match.
|
|
3155
|
+
NEBIUS_EU_REGIONS = frozenset(
|
|
3156
|
+
r.strip().lower() for r in envvar("NEBIUS_EU_REGIONS", "eu-north1,eu-west1,eu-west2").split(",")
|
|
3157
|
+
if r.strip()
|
|
3158
|
+
)
|
|
3159
|
+
|
|
3160
|
+
#: Instance states, READ from the OpenAPI enum `nebius.compute.v1.InstanceStatus.InstanceState`:
|
|
3161
|
+
#: UNSPECIFIED, CREATING, UPDATING, STARTING, RUNNING, STOPPING, STOPPED, DELETING, ERROR.
|
|
3162
|
+
#:
|
|
3163
|
+
#: SERVING is `RUNNING` alone. Everything the list returns still exists and can still bill, so
|
|
3164
|
+
#: there is no `_DEAD` set here at all: for Nebius, GONE means ABSENT FROM THE LIST. `DELETING` is
|
|
3165
|
+
#: deliberately counted as still-present — a deletion in flight is not a deletion observed, and the
|
|
3166
|
+
#: teardown's own verification is what establishes the difference.
|
|
3167
|
+
NEBIUS_SERVING_STATES = frozenset({"RUNNING"})
|
|
3168
|
+
|
|
3169
|
+
|
|
3170
|
+
def nebius_price_table() -> "Dict[str, float]":
|
|
3171
|
+
"""The operator's `platform/preset -> price per hour` table, parsed. `{}` when unset.
|
|
3172
|
+
|
|
3173
|
+
ONE PARSER AND ONE ENV READ, shared by `gpuprices._fetch_nebius` (the merit order) and
|
|
3174
|
+
`derive_nebius_pod_spec` (the live proof's default). It lives here rather than in `gpuprices`
|
|
3175
|
+
because that module already imports this one, so the other direction would be a cycle.
|
|
3176
|
+
|
|
3177
|
+
WHY IT IS NOT TWO FUNCTIONS. The second copy was written before this one existed, and the two
|
|
3178
|
+
read the same variable with the same JSON shape for the same purpose — the exact duplication
|
|
3179
|
+
this repo's rule 12 is about, and the kind that drifts: a fix to one copy's number handling
|
|
3180
|
+
would silently not reach the other, so the price the race ranks on and the price the cap is
|
|
3181
|
+
checked against could disagree.
|
|
3182
|
+
|
|
3183
|
+
A ROW THAT WILL NOT PARSE IS DROPPED, NOT ZEROED. A zero price is the cheapest thing in any
|
|
3184
|
+
catalog and would win every race; an absent one simply does not compete.
|
|
3185
|
+
"""
|
|
3186
|
+
raw = (envvar("NEBIUS_PRICES", "") or "").strip()
|
|
3187
|
+
if not raw:
|
|
3188
|
+
return {}
|
|
3189
|
+
try:
|
|
3190
|
+
parsed = json.loads(raw)
|
|
3191
|
+
except Exception as e:
|
|
3192
|
+
log.error("nebius: FRUTEBOX_NEBIUS_PRICES is not valid JSON (%s) — no operator prices",
|
|
3193
|
+
type(e).__name__)
|
|
3194
|
+
return {}
|
|
3195
|
+
if not isinstance(parsed, dict):
|
|
3196
|
+
log.error("nebius: FRUTEBOX_NEBIUS_PRICES must be a JSON object of "
|
|
3197
|
+
"'platform/preset': price_per_hour — no operator prices")
|
|
3198
|
+
return {}
|
|
3199
|
+
out: Dict[str, float] = {}
|
|
3200
|
+
for k, v in parsed.items():
|
|
3201
|
+
try:
|
|
3202
|
+
price = float(v)
|
|
3203
|
+
except (TypeError, ValueError):
|
|
3204
|
+
continue
|
|
3205
|
+
if price > 0:
|
|
3206
|
+
out[str(k)] = price
|
|
3207
|
+
return out
|
|
3208
|
+
|
|
3209
|
+
|
|
3210
|
+
def _b64url(raw: bytes) -> str:
|
|
3211
|
+
"""JWS base64url: no padding. A padded segment is rejected by the token endpoint."""
|
|
3212
|
+
import base64
|
|
3213
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
3214
|
+
|
|
3215
|
+
|
|
3216
|
+
def nebius_jwt(*, key_id: str, service_account_id: str, private_key_pem: str,
|
|
3217
|
+
now: "Optional[float]" = None, ttl_s: int = 300) -> str:
|
|
3218
|
+
"""The signed assertion that is exchanged for an IAM token. RS256, five-minute life.
|
|
3219
|
+
|
|
3220
|
+
Claims, from the vendor's own documented flow: `kid` (the AUTHORIZED KEY's id) in the header,
|
|
3221
|
+
`iss` and `sub` both the SERVICE ACCOUNT's id, and `exp`. Short-lived on purpose — it is used
|
|
3222
|
+
once, immediately, to obtain the real token, so a longer life buys nothing and widens the
|
|
3223
|
+
window in which a captured assertion is useful.
|
|
3224
|
+
|
|
3225
|
+
A MODULE-LEVEL FUNCTION rather than a method, so the signing can be tested against a generated
|
|
3226
|
+
key pair without constructing a provider or touching a credential store — and so it is
|
|
3227
|
+
obviously one pure function of its inputs. It takes the PEM as a string and never a path: the
|
|
3228
|
+
key reaches this process through the credential store, not off a disk this code chose.
|
|
3229
|
+
"""
|
|
3230
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
3231
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
3232
|
+
|
|
3233
|
+
key = serialization.load_pem_private_key(private_key_pem.encode(), password=None)
|
|
3234
|
+
if not isinstance(key, rsa.RSAPrivateKey):
|
|
3235
|
+
# RS256 is what the vendor documents. An EC key here would sign something the endpoint
|
|
3236
|
+
# cannot verify, and the resulting 401 would look like a wrong key rather than a wrong
|
|
3237
|
+
# ALGORITHM — a diagnosis worth hours.
|
|
3238
|
+
raise ValueError("nebius: the authorized key's private key is not RSA; RS256 is required")
|
|
3239
|
+
issued = int(now if now is not None else time.time())
|
|
3240
|
+
header = {"alg": "RS256", "typ": "JWT", "kid": key_id}
|
|
3241
|
+
claims = {"iss": service_account_id, "sub": service_account_id,
|
|
3242
|
+
"iat": issued, "exp": issued + ttl_s}
|
|
3243
|
+
signing_input = f"{_b64url(json.dumps(header).encode())}.{_b64url(json.dumps(claims).encode())}"
|
|
3244
|
+
sig = key.sign(signing_input.encode(), padding.PKCS1v15(), hashes.SHA256())
|
|
3245
|
+
return f"{signing_input}.{_b64url(sig)}"
|
|
3246
|
+
|
|
3247
|
+
|
|
3248
|
+
class NebiusProvider:
|
|
3249
|
+
"""Nebius AI Cloud GPU, through the same `GpuProvider` protocol as every other backend.
|
|
3250
|
+
|
|
3251
|
+
Second in the EU preference order, behind Verda.
|
|
3252
|
+
"""
|
|
3253
|
+
|
|
3254
|
+
name = "nebius"
|
|
3255
|
+
_BASE = NEBIUS_BASE
|
|
3256
|
+
|
|
3257
|
+
#: IT DISTINGUISHES, and this is a reading rather than a hope. The previous `False` said the
|
|
3258
|
+
#: list response had not been read; it has been now — the OpenAPI document publishes the
|
|
3259
|
+
#: `InstanceState` enum, so `RUNNING` is separable from `STOPPED` and from a creation still in
|
|
3260
|
+
#: flight. `test_gpuproviders.py`'s contract test refuses a `True` the code does not back up,
|
|
3261
|
+
#: so this is only legal while both enumerations exist below.
|
|
3262
|
+
DISTINGUISHES_SERVING = True
|
|
3263
|
+
|
|
3264
|
+
def __init__(self) -> None:
|
|
3265
|
+
self._token = ""
|
|
3266
|
+
self._token_expires = 0.0
|
|
3267
|
+
#: The project whose region has already been checked, so the check is one call per project
|
|
3268
|
+
#: per process and not one per create. Cleared with the token.
|
|
3269
|
+
self._eu_checked: Set[str] = set()
|
|
3270
|
+
#: The project `_resolve_project` found for `NEBIUS_REGION`, once. Empty until it has run
|
|
3271
|
+
#: and empty if it could not read the account — never a stand-in for one it did not find.
|
|
3272
|
+
self._discovered_project: str = ""
|
|
3273
|
+
#: project id -> the region `_assert_eu_project` read for it. Kept because the image
|
|
3274
|
+
#: catalog REQUIRES a region ("validation error: region: value is required") and the EU
|
|
3275
|
+
#: gate has already established it — asking IAM twice for the same fact, or worse guessing
|
|
3276
|
+
#: it from the project's NAME, would be two ways of being wrong about jurisdiction.
|
|
3277
|
+
self._project_region: Dict[str, str] = {}
|
|
3278
|
+
|
|
3279
|
+
# ---- credential ------------------------------------------------------------------------
|
|
3280
|
+
|
|
3281
|
+
def _key(self) -> dict:
|
|
3282
|
+
"""The authorized key as a dict, or `{}`.
|
|
3283
|
+
|
|
3284
|
+
ONE ENV VAR HOLDING A JSON DOCUMENT, which is unlike every other provider here, so it is
|
|
3285
|
+
parsed in one place. A malformed key is `{}` — not a partial dict — because a half-read
|
|
3286
|
+
credential that still looks truthy is how a provider reports ready and then cannot sign.
|
|
3287
|
+
|
|
3288
|
+
FIELD NAMES ARE THE VENDOR'S OWN, as they appear in the key file IAM issues: `id` is the
|
|
3289
|
+
AUTHORIZED KEY's id (the JWT's `kid`), `service_account_id` is the account it belongs to,
|
|
3290
|
+
and `private_key` is the PEM. `subject-credentials` wrappers are not unwrapped here: a key
|
|
3291
|
+
file in a shape we have not seen is reported as missing fields rather than guessed at.
|
|
3292
|
+
"""
|
|
3293
|
+
raw = _provider_credential("nebius", "NEBIUS_SERVICE_ACCOUNT_KEY")
|
|
3294
|
+
if not raw:
|
|
3295
|
+
return self._key_from_parts()
|
|
3296
|
+
try:
|
|
3297
|
+
doc = json.loads(raw)
|
|
3298
|
+
except Exception:
|
|
3299
|
+
# NOT the contents. A parse error on a credential must not put the credential in a log.
|
|
3300
|
+
log.error("nebius: NEBIUS_SERVICE_ACCOUNT_KEY is not valid JSON — refusing to use it")
|
|
3301
|
+
return {}
|
|
3302
|
+
if not isinstance(doc, dict):
|
|
3303
|
+
return {}
|
|
3304
|
+
# The three fields an authorized key must have to be signable. Reported as names only.
|
|
3305
|
+
missing = [f for f in ("id", "service_account_id", "private_key") if not doc.get(f)]
|
|
3306
|
+
if missing:
|
|
3307
|
+
log.error("nebius: authorized key is missing %s", ",".join(missing))
|
|
3308
|
+
return {}
|
|
3309
|
+
return doc
|
|
3310
|
+
|
|
3311
|
+
def _key_from_parts(self) -> dict:
|
|
3312
|
+
"""The same authorized key, assembled from the three parts under their own names.
|
|
3313
|
+
|
|
3314
|
+
WHY THIS EXISTS. `NEBIUS_SERVICE_ACCOUNT_KEY` is one env var holding a JSON document, and
|
|
3315
|
+
IAM does not hand you that document — it hands you a key id, an account id, and a PEM, in
|
|
3316
|
+
three places. So using this provider required hand-building JSON around a private key,
|
|
3317
|
+
which is both the easiest thing to get wrong and the easiest thing to leave in a shell
|
|
3318
|
+
history. On 2026-10-20 the estate file held Nebius credentials and this provider still
|
|
3319
|
+
reported itself unconfigured, because nothing could assemble that document from the parts
|
|
3320
|
+
that actually existed on the machine.
|
|
3321
|
+
|
|
3322
|
+
The JSON form stays primary (it is what IAM's downloadable key file becomes, and it is one
|
|
3323
|
+
secret instead of three). This is the fallback, and it reports WHICH part is missing: with
|
|
3324
|
+
one var the only possible answer was "no credential", which is indistinguishable from
|
|
3325
|
+
"three quarters of a credential".
|
|
3326
|
+
|
|
3327
|
+
`NEBIUS_PRIVATE_KEY_FILE` names a path rather than carrying the PEM, which is how the key
|
|
3328
|
+
reaches an operator's shell in the first place. `nebius_jwt` still takes the PEM as a
|
|
3329
|
+
string and never a path — the disk this reads is the one the OPERATOR named, not one this
|
|
3330
|
+
code went looking for.
|
|
3331
|
+
"""
|
|
3332
|
+
key_id = _provider_credential("nebius", "NEBIUS_PUBLIC_KEY_ID")
|
|
3333
|
+
sa_id = _provider_credential("nebius", "NEBIUS_SERVICE_ACCOUNT_ID")
|
|
3334
|
+
pem = _provider_credential("nebius", "NEBIUS_PRIVATE_KEY")
|
|
3335
|
+
if not pem:
|
|
3336
|
+
path = _provider_credential("nebius", "NEBIUS_PRIVATE_KEY_FILE")
|
|
3337
|
+
if path:
|
|
3338
|
+
try:
|
|
3339
|
+
with open(os.path.expanduser(path), "r", encoding="utf-8") as fh:
|
|
3340
|
+
pem = fh.read()
|
|
3341
|
+
except Exception as e:
|
|
3342
|
+
# The PATH may be logged; what is at the end of it may not.
|
|
3343
|
+
log.error("nebius: NEBIUS_PRIVATE_KEY_FILE could not be read (%s)",
|
|
3344
|
+
type(e).__name__)
|
|
3345
|
+
return {}
|
|
3346
|
+
if not (key_id or sa_id or pem):
|
|
3347
|
+
# Nothing of this provider is configured. THAT is not the same as a broken key, so it
|
|
3348
|
+
# is not reported as one — but say so if the one credential in the estate is the one
|
|
3349
|
+
# that cannot sign, because that diagnosis took a day to make once.
|
|
3350
|
+
access_key = _provider_credential("nebius", "NEBIUS_KEY_ID")
|
|
3351
|
+
if access_key:
|
|
3352
|
+
log.error(
|
|
3353
|
+
"nebius: NEBIUS_KEY_ID/NEBIUS_SECRET_KEY are an OBJECT-STORAGE access key "
|
|
3354
|
+
"(IAM rejects the id as 'Invalid nid format'); the compute API needs a "
|
|
3355
|
+
"service-account authorized key — set NEBIUS_SERVICE_ACCOUNT_ID, "
|
|
3356
|
+
"NEBIUS_PUBLIC_KEY_ID and NEBIUS_PRIVATE_KEY_FILE")
|
|
3357
|
+
return {}
|
|
3358
|
+
missing = [n for n, v in (("NEBIUS_PUBLIC_KEY_ID", key_id),
|
|
3359
|
+
("NEBIUS_SERVICE_ACCOUNT_ID", sa_id),
|
|
3360
|
+
("NEBIUS_PRIVATE_KEY/_FILE", pem)) if not v]
|
|
3361
|
+
if missing:
|
|
3362
|
+
log.error("nebius: authorized key is incomplete, missing %s", ",".join(missing))
|
|
3363
|
+
return {}
|
|
3364
|
+
return {"id": key_id, "service_account_id": sa_id, "private_key": pem}
|
|
3365
|
+
|
|
3366
|
+
def _project_id(self) -> str:
|
|
3367
|
+
"""The project every call is scoped to — `metadata.parentId` on create, `parentId` on list.
|
|
3368
|
+
|
|
3369
|
+
ACCOUNT-SCOPED, NOT A SECRET (§A5: the owner supplies the credential, the code discovers
|
|
3370
|
+
the account's resources). It used to be required configuration on the reasoning that
|
|
3371
|
+
"picking one for the operator would be choosing where the money is spent and which
|
|
3372
|
+
jurisdiction the data lands in" — and that reasoning was right about the CHOICE and wrong
|
|
3373
|
+
about who had to type it. The owner has now made the choice, by region: EU work runs in
|
|
3374
|
+
`NEBIUS_REGION` (eu-north1). Which project id that is, in this account, is an account
|
|
3375
|
+
resource — exactly the kind of thing §A5 says the code discovers.
|
|
3376
|
+
|
|
3377
|
+
So: explicit configuration still wins, and otherwise `_resolve_project` finds the project
|
|
3378
|
+
whose own region IS the named one. Nothing is guessed either way; a region with no project
|
|
3379
|
+
resolves to "" and every caller refuses, which is what it did before.
|
|
3380
|
+
"""
|
|
3381
|
+
explicit = _provider_credential("nebius", "NEBIUS_PROJECT_ID").strip()
|
|
3382
|
+
return explicit or self._discovered_project
|
|
3383
|
+
|
|
3384
|
+
async def _resolve_project(self) -> str:
|
|
3385
|
+
"""The project id for `NEBIUS_REGION`, discovered once and remembered.
|
|
3386
|
+
|
|
3387
|
+
One listing, cached for the process: the account's project set changes on the timescale of
|
|
3388
|
+
someone creating a project, and re-asking per create would put an IAM round trip on the
|
|
3389
|
+
spend path for an answer that does not move.
|
|
3390
|
+
|
|
3391
|
+
A FAILED LISTING IS NOT "NO PROJECT". It leaves the cache empty and returns "", so the
|
|
3392
|
+
caller refuses — the same answer an unconfigured deployment gets, and never a different
|
|
3393
|
+
region's project standing in for the one we could not read.
|
|
3394
|
+
"""
|
|
3395
|
+
explicit = _provider_credential("nebius", "NEBIUS_PROJECT_ID").strip()
|
|
3396
|
+
if explicit or self._discovered_project:
|
|
3397
|
+
return explicit or self._discovered_project
|
|
3398
|
+
want = (NEBIUS_REGION or "").strip().lower()
|
|
3399
|
+
if not want:
|
|
3400
|
+
return ""
|
|
3401
|
+
headers = await self._headers()
|
|
3402
|
+
if headers is None:
|
|
3403
|
+
return ""
|
|
3404
|
+
try:
|
|
3405
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
3406
|
+
t = await c.get(f"{self._BASE}/iam/v1/tenants", headers=headers)
|
|
3407
|
+
if t.status_code >= 300:
|
|
3408
|
+
log.error("nebius: tenant listing HTTP %s — cannot resolve the %s project",
|
|
3409
|
+
t.status_code, want)
|
|
3410
|
+
return ""
|
|
3411
|
+
for ten in ((t.json() or {}).get("items") or []):
|
|
3412
|
+
tid = str(((ten.get("metadata") or {}).get("id") or ""))
|
|
3413
|
+
if not tid:
|
|
3414
|
+
continue
|
|
3415
|
+
r = await c.get(f"{self._BASE}{NEBIUS_PROJECTS_PATH}", headers=headers,
|
|
3416
|
+
params={"parentId": tid})
|
|
3417
|
+
if r.status_code >= 300:
|
|
3418
|
+
log.error("nebius: project listing HTTP %s for tenant — cannot resolve "
|
|
3419
|
+
"the %s project", r.status_code, want)
|
|
3420
|
+
return ""
|
|
3421
|
+
for pr in ((r.json() or {}).get("items") or []):
|
|
3422
|
+
pid = str(((pr.get("metadata") or {}).get("id") or ""))
|
|
3423
|
+
regn = str(((pr.get("status") or {}).get("region")
|
|
3424
|
+
or (pr.get("spec") or {}).get("region") or "")).lower()
|
|
3425
|
+
if pid and regn:
|
|
3426
|
+
self._project_region[pid] = regn
|
|
3427
|
+
if pid and regn == want:
|
|
3428
|
+
self._discovered_project = pid
|
|
3429
|
+
log.info("nebius: EU work scoped to %s (region %s), discovered from "
|
|
3430
|
+
"the account rather than configured", pid, regn)
|
|
3431
|
+
return pid
|
|
3432
|
+
except Exception as e:
|
|
3433
|
+
log.error("nebius: project discovery failed (%s) — refusing rather than scoping "
|
|
3434
|
+
"calls to a project nobody chose", type(e).__name__)
|
|
3435
|
+
return ""
|
|
3436
|
+
log.error("nebius: the account holds no project in %s, so there is nothing to scope EU "
|
|
3437
|
+
"work to. Set FRUTEBOX_NEBIUS_PROJECT_ID or create the project.", want)
|
|
3438
|
+
return ""
|
|
3439
|
+
|
|
3440
|
+
def configured(self) -> bool:
|
|
3441
|
+
"""A parseable authorized key AND a project to scope calls to — or a region to find one in.
|
|
3442
|
+
|
|
3443
|
+
A key alone is not the ability to call anything: every compute operation needs a
|
|
3444
|
+
`parentId`, so reporting `True` without one is how a provider wins a race it cannot serve.
|
|
3445
|
+
That invariant is unchanged; what changed is that the project may now be DISCOVERED from
|
|
3446
|
+
`NEBIUS_REGION` instead of typed, and discovery needs an async call that cannot happen
|
|
3447
|
+
inside this sync predicate. Returning False until it had run would mean it never ran — no
|
|
3448
|
+
caller asks an unconfigured provider anything — so a named region counts here.
|
|
3449
|
+
|
|
3450
|
+
The race argument survives intact one level down: `create` resolves the project first and
|
|
3451
|
+
refuses when it cannot, before anything is POSTed or billed. So the worst case is a
|
|
3452
|
+
provider that enters a race and declines it for a named reason, which is what a stock-out
|
|
3453
|
+
already looks like.
|
|
3454
|
+
"""
|
|
3455
|
+
if not self._key():
|
|
3456
|
+
return False
|
|
3457
|
+
return bool(self._project_id() or (NEBIUS_REGION or "").strip())
|
|
3458
|
+
|
|
3459
|
+
# ---- auth ------------------------------------------------------------------------------
|
|
3460
|
+
|
|
3461
|
+
async def _access_token(self) -> str:
|
|
3462
|
+
"""A cached IAM token, refreshed a minute before it actually expires.
|
|
3463
|
+
|
|
3464
|
+
The margin is the same one Verda's token has and for the same reason: a token that passes
|
|
3465
|
+
the check here and expires in flight produces a 401 on the CREATE call, and a create that
|
|
3466
|
+
fails ambiguously is the one failure mode that can leave an instance running that nobody
|
|
3467
|
+
recorded.
|
|
3468
|
+
|
|
3469
|
+
Returns `""` rather than raising, for every failure — a missing credential, an unsignable
|
|
3470
|
+
key, a rejected assertion. Callers fail the way the contract says ("nothing running" /
|
|
3471
|
+
"did not create"), never by raising out of a provider method.
|
|
3472
|
+
"""
|
|
3473
|
+
now = time.time()
|
|
3474
|
+
if self._token and now < self._token_expires:
|
|
3475
|
+
return self._token
|
|
3476
|
+
key = self._key()
|
|
3477
|
+
if not key:
|
|
3478
|
+
return ""
|
|
3479
|
+
try:
|
|
3480
|
+
assertion = nebius_jwt(key_id=str(key["id"]),
|
|
3481
|
+
service_account_id=str(key["service_account_id"]),
|
|
3482
|
+
private_key_pem=str(key["private_key"]))
|
|
3483
|
+
except Exception as e:
|
|
3484
|
+
# The TYPE only. A signing failure must not put the private key in a log.
|
|
3485
|
+
log.error("nebius: could not sign the token assertion (%s) — check that the "
|
|
3486
|
+
"authorized key's private_key is an RSA PEM", type(e).__name__)
|
|
3487
|
+
return ""
|
|
3488
|
+
try:
|
|
3489
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
3490
|
+
r = await c.post(NEBIUS_AUTH_URL, data={
|
|
3491
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
3492
|
+
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
|
3493
|
+
"subject_token": assertion,
|
|
3494
|
+
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
|
|
3495
|
+
})
|
|
3496
|
+
except Exception as e:
|
|
3497
|
+
log.warning("nebius: token exchange unreachable (%s)", type(e).__name__)
|
|
3498
|
+
return ""
|
|
3499
|
+
if r.status_code >= 300:
|
|
3500
|
+
# The STATUS, never the body: an auth error body can echo the assertion back.
|
|
3501
|
+
log.error("nebius: token exchange refused the service-account assertion (HTTP %s)",
|
|
3502
|
+
r.status_code)
|
|
3503
|
+
return ""
|
|
3504
|
+
token = str((r.json() or {}).get("access_token") or "")
|
|
3505
|
+
if not token:
|
|
3506
|
+
return ""
|
|
3507
|
+
self._token = token
|
|
3508
|
+
self._token_expires = now + max(0.0, float((r.json() or {}).get("expires_in") or 43200)) - 60
|
|
3509
|
+
return token
|
|
3510
|
+
|
|
3511
|
+
async def _headers(self) -> "Optional[dict]":
|
|
3512
|
+
"""Auth headers, or None when no token could be obtained.
|
|
3513
|
+
|
|
3514
|
+
None rather than an empty bearer: `Authorization: Bearer ` is an ILLEGAL header value and
|
|
3515
|
+
httpx raises `LocalProtocolError` on it, so a credential problem would surface as a
|
|
3516
|
+
protocol crash from inside the transport.
|
|
3517
|
+
"""
|
|
3518
|
+
token = await self._access_token()
|
|
3519
|
+
if not token:
|
|
3520
|
+
return None
|
|
3521
|
+
return {"Authorization": f"Bearer {token}", "content-type": "application/json"}
|
|
3522
|
+
|
|
3523
|
+
# ---- the EU check ----------------------------------------------------------------------
|
|
3524
|
+
|
|
3525
|
+
async def _assert_eu_project(self, project_id: str) -> bool:
|
|
3526
|
+
"""Is this project's hardware in the EU? Checked, once per project, before any create.
|
|
3527
|
+
|
|
3528
|
+
WHY THIS EXISTS AT ALL. `CreateInstanceRequest` carries no region: `metadata.parentId`
|
|
3529
|
+
names a project and the PROJECT's `spec.region` decides where the metal is. So there is no
|
|
3530
|
+
field in a create body that an EU gate could inspect — the only honest check is to ask IAM
|
|
3531
|
+
what region the project is in, which is what this does.
|
|
3532
|
+
|
|
3533
|
+
A FAILED CHECK IS NOT A PASS. If the project cannot be read, this returns False and the
|
|
3534
|
+
create is refused. That is the opposite of the usual "degrade toward launching" trade in
|
|
3535
|
+
this file, and deliberately so: everywhere else a failed read costs capacity, while here it
|
|
3536
|
+
would mean placing EU-labelled work on hardware whose jurisdiction was never established.
|
|
3537
|
+
"We could not tell" is not a defence to a regulator — the same call `regionmap`'s residency
|
|
3538
|
+
gate and `provider_of_node` already make.
|
|
3539
|
+
"""
|
|
3540
|
+
if project_id in self._eu_checked:
|
|
3541
|
+
return True
|
|
3542
|
+
headers = await self._headers()
|
|
3543
|
+
if headers is None:
|
|
3544
|
+
return False
|
|
3545
|
+
try:
|
|
3546
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
3547
|
+
r = await c.get(f"{self._BASE}{NEBIUS_PROJECTS_PATH}/{_urlquote(project_id)}",
|
|
3548
|
+
headers=headers)
|
|
3549
|
+
except Exception as e:
|
|
3550
|
+
log.error("nebius: could not read project %s to establish its region (%s) — refusing "
|
|
3551
|
+
"to create. An unverified jurisdiction is not an EU one.",
|
|
3552
|
+
project_id, type(e).__name__)
|
|
3553
|
+
return False
|
|
3554
|
+
if r.status_code >= 300:
|
|
3555
|
+
log.error("nebius: could not read project %s to establish its region (HTTP %s) — "
|
|
3556
|
+
"refusing to create.", project_id, r.status_code)
|
|
3557
|
+
return False
|
|
3558
|
+
region = str(((r.json() or {}).get("spec") or {}).get("region") or "").strip().lower()
|
|
3559
|
+
if not region:
|
|
3560
|
+
log.error("nebius: project %s reports no region — refusing to create.", project_id)
|
|
3561
|
+
return False
|
|
3562
|
+
if region not in NEBIUS_EU_REGIONS:
|
|
3563
|
+
log.error("nebius: project %s is in region %r, which is not EU. Permitted: %s. "
|
|
3564
|
+
"(eu-north2 is ICELAND — EEA, not EU.)",
|
|
3565
|
+
project_id, region, ",".join(sorted(NEBIUS_EU_REGIONS)))
|
|
3566
|
+
return False
|
|
3567
|
+
self._eu_checked.add(project_id)
|
|
3568
|
+
self._project_region[project_id] = region
|
|
3569
|
+
log.info("nebius: project %s confirmed in EU region %s", project_id, region)
|
|
3570
|
+
return True
|
|
3571
|
+
|
|
3572
|
+
# ---- enumeration -----------------------------------------------------------------------
|
|
3573
|
+
|
|
3574
|
+
async def _list(self) -> "Optional[List[dict]]":
|
|
3575
|
+
"""Every instance in the project, across pages, or None when it could not be ASKED.
|
|
3576
|
+
|
|
3577
|
+
None IS NOT AN EMPTY LIST. An unreadable list read as "nothing is there" is this
|
|
3578
|
+
codebase's most expensive recurring mistake, and here it would mean a teardown reporting a
|
|
3579
|
+
clear label while instances bill.
|
|
3580
|
+
|
|
3581
|
+
PAGINATION IS FOLLOWED, which matters more than it looks: a truncated first page read as
|
|
3582
|
+
the whole fleet is the same defect wearing a different hat — the pool would miss its own
|
|
3583
|
+
instances and `start()` would buy duplicates. The page loop is bounded so a server that
|
|
3584
|
+
keeps handing back the same token cannot spin here forever.
|
|
3585
|
+
"""
|
|
3586
|
+
headers = await self._headers()
|
|
3587
|
+
if headers is None:
|
|
3588
|
+
return None
|
|
3589
|
+
project = await self._resolve_project()
|
|
3590
|
+
if not project:
|
|
3591
|
+
return None
|
|
3592
|
+
out: List[dict] = []
|
|
3593
|
+
token = ""
|
|
3594
|
+
try:
|
|
3595
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
3596
|
+
for _ in range(50):
|
|
3597
|
+
params = {"parentId": project, "pageSize": "100"}
|
|
3598
|
+
if token:
|
|
3599
|
+
params["pageToken"] = token
|
|
3600
|
+
r = await c.get(f"{self._BASE}{NEBIUS_INSTANCES_PATH}", headers=headers,
|
|
3601
|
+
params=params)
|
|
3602
|
+
if r.status_code >= 300:
|
|
3603
|
+
log.warning("nebius: instance list HTTP %s", r.status_code)
|
|
3604
|
+
return None
|
|
3605
|
+
body = r.json() or {}
|
|
3606
|
+
out.extend(i for i in (body.get("items") or []) if isinstance(i, dict))
|
|
3607
|
+
token = str(body.get("nextPageToken") or "")
|
|
3608
|
+
if not token:
|
|
3609
|
+
return out
|
|
3610
|
+
except Exception as e:
|
|
3611
|
+
log.warning("nebius: could not list instances (%s)", type(e).__name__)
|
|
3612
|
+
return None
|
|
3613
|
+
log.warning("nebius: instance list did not terminate after 50 pages — returning what was "
|
|
3614
|
+
"read so far rather than looping, but treat this fleet view as partial")
|
|
3615
|
+
return out
|
|
3616
|
+
|
|
3617
|
+
@staticmethod
|
|
3618
|
+
def _state(row: dict) -> str:
|
|
3619
|
+
return str(((row.get("status") or {}).get("state") or "")).strip().upper()
|
|
3620
|
+
|
|
3621
|
+
@staticmethod
|
|
3622
|
+
def _label(row: dict) -> str:
|
|
3623
|
+
return str(((row.get("metadata") or {}).get("name") or ""))
|
|
3624
|
+
|
|
3625
|
+
@staticmethod
|
|
3626
|
+
def _id(row: dict) -> str:
|
|
3627
|
+
return str(((row.get("metadata") or {}).get("id") or ""))
|
|
3628
|
+
|
|
3629
|
+
def _matching(self, rows: "List[dict]", label: str,
|
|
3630
|
+
states: "Optional[frozenset]" = None) -> List[Instance]:
|
|
3631
|
+
out: List[Instance] = []
|
|
3632
|
+
for row in rows:
|
|
3633
|
+
if self._label(row) != label:
|
|
3634
|
+
continue
|
|
3635
|
+
state = self._state(row)
|
|
3636
|
+
if states is not None and state not in states:
|
|
3637
|
+
continue
|
|
3638
|
+
out.append(Instance(id=self._id(row), label=label, status=state))
|
|
3639
|
+
return out
|
|
3640
|
+
|
|
3641
|
+
async def find(self, label: str) -> List[Instance]:
|
|
3642
|
+
"""THE BASE PRIMITIVE: everything in the project carrying this label.
|
|
3643
|
+
|
|
3644
|
+
GONE MEANS ABSENT. There is no status this treats as dead — every state the list returns is
|
|
3645
|
+
an instance that exists and can bill, `DELETING` included, because a deletion in flight is
|
|
3646
|
+
not a deletion observed.
|
|
3647
|
+
"""
|
|
3648
|
+
rows = await self._list()
|
|
3649
|
+
return self._matching(rows or [], label)
|
|
3650
|
+
|
|
3651
|
+
async def find_live(self, label: str) -> List[Instance]:
|
|
3652
|
+
"""CAPACITY: only `RUNNING`. A `STOPPED` instance keeps its disks and serves nothing, and a
|
|
3653
|
+
`CREATING` one cannot take a turn yet — counting either as capacity holds a pool slot that
|
|
3654
|
+
nothing is serving from."""
|
|
3655
|
+
rows = await self._list()
|
|
3656
|
+
return self._matching(rows or [], label, NEBIUS_SERVING_STATES)
|
|
3657
|
+
|
|
3658
|
+
async def find_all(self, label: str) -> List[Instance]:
|
|
3659
|
+
"""TEARDOWN: everything that could still bill — which here is everything that is listed."""
|
|
3660
|
+
rows = await self._list()
|
|
3661
|
+
return self._matching(rows or [], label)
|
|
3662
|
+
|
|
3663
|
+
async def status(self, label: str) -> dict:
|
|
3664
|
+
"""The vendor's own state for each instance under this label, unflattened.
|
|
3665
|
+
|
|
3666
|
+
`{"ok": False}` when the project could not be asked — never an empty `instances` list,
|
|
3667
|
+
which would read as "the label is clear".
|
|
3668
|
+
"""
|
|
3669
|
+
rows = await self._list()
|
|
3670
|
+
if rows is None:
|
|
3671
|
+
return {"ok": False, "provider": self.name, "label": label,
|
|
3672
|
+
"detail": "could not list instances — this is NOT 'the label is clear'"}
|
|
3673
|
+
mine = [r for r in rows if self._label(r) == label]
|
|
3674
|
+
return {
|
|
3675
|
+
"ok": True,
|
|
3676
|
+
"provider": self.name,
|
|
3677
|
+
"label": label,
|
|
3678
|
+
"project": self._project_id(),
|
|
3679
|
+
"instances": [{"id": self._id(r), "state": self._state(r),
|
|
3680
|
+
"serving": self._state(r) in NEBIUS_SERVING_STATES,
|
|
3681
|
+
"billing": True,
|
|
3682
|
+
"boot_disk": (((r.get("spec") or {}).get("bootDisk") or {})
|
|
3683
|
+
.get("existingDisk", {}) or {}).get("id", ""),
|
|
3684
|
+
"platform": (((r.get("spec") or {}).get("resources") or {})
|
|
3685
|
+
.get("platform", "")),
|
|
3686
|
+
"preset": (((r.get("spec") or {}).get("resources") or {})
|
|
3687
|
+
.get("preset", ""))}
|
|
3688
|
+
for r in mine],
|
|
3689
|
+
}
|
|
3690
|
+
|
|
3691
|
+
async def list_account(self) -> "Optional[List[dict]]":
|
|
3692
|
+
"""Every instance in the PROJECT, label-agnostic, normalised. `None` when unaskable.
|
|
3693
|
+
|
|
3694
|
+
"Account" is the project here, because that is the widest thing one credential can
|
|
3695
|
+
enumerate — Nebius scopes every compute read to a `parentId`. SO THIS IS NOT A WHOLE-TENANT
|
|
3696
|
+
SWEEP, and saying so matters: an orphan in a DIFFERENT project of the same tenant is
|
|
3697
|
+
invisible to it, and would stay invisible while billing. `podreaper` reports the project it
|
|
3698
|
+
swept for exactly that reason.
|
|
3699
|
+
"""
|
|
3700
|
+
rows = await self._list()
|
|
3701
|
+
if rows is None:
|
|
3702
|
+
return None
|
|
3703
|
+
return [{"id": self._id(r),
|
|
3704
|
+
"label": self._label(r),
|
|
3705
|
+
"status": self._state(r),
|
|
3706
|
+
"created_at": ((r.get("metadata") or {}).get("createdAt") or ""),
|
|
3707
|
+
"price_per_hour": None, # the vendor publishes no price on the instance
|
|
3708
|
+
"location": self._project_id(),
|
|
3709
|
+
"billing": True} # listed means present means billable
|
|
3710
|
+
for r in rows]
|
|
3711
|
+
|
|
3712
|
+
# ---- create / destroy ------------------------------------------------------------------
|
|
3713
|
+
|
|
3714
|
+
async def create(self, label: str, spec: dict) -> bool:
|
|
3715
|
+
"""Create ONE instance carrying THIS label, in a project proven to be in the EU.
|
|
3716
|
+
|
|
3717
|
+
THE SPEC COMES FROM THE OPERATOR, like `RUNPOD_POD_SPEC` and `ALIBABA_ECS_SPEC` (the
|
|
3718
|
+
2026-09-24 `VAST_POD_SPEC` ruling): `platform`, `preset`, the boot image and the subnet are
|
|
3719
|
+
the decisions that pick hardware and cost money, and a provider that guesses them deploys
|
|
3720
|
+
the wrong machine and bills for it. This refuses without them rather than inventing an
|
|
3721
|
+
H100 preset.
|
|
3722
|
+
|
|
3723
|
+
THE BOOT DISK IS VM-MANAGED ON PURPOSE, and it is a money decision rather than a style one.
|
|
3724
|
+
The vendor: "If VM-managed disks are attached to your VM, they are deleted along with the
|
|
3725
|
+
VM automatically" — while standalone disks are not. So a managed boot disk makes `destroy`
|
|
3726
|
+
complete by construction, which is the opposite of Verda, where the volume has to be named
|
|
3727
|
+
explicitly. `forbidDeletion` is never set, for the same reason.
|
|
3728
|
+
"""
|
|
3729
|
+
project = await self._resolve_project()
|
|
3730
|
+
if not project:
|
|
3731
|
+
log.error("nebius: refusing to create %r without NEBIUS_PROJECT_ID — every compute "
|
|
3732
|
+
"call is scoped to a project, and the project decides the region", label)
|
|
3733
|
+
return False
|
|
3734
|
+
|
|
3735
|
+
platform = str(spec.get("platform") or "").strip()
|
|
3736
|
+
preset = str(spec.get("preset") or "").strip()
|
|
3737
|
+
image_family = str(spec.get("imageFamily") or spec.get("image_family") or "").strip()
|
|
3738
|
+
image_id = str(spec.get("imageId") or spec.get("image_id") or "").strip()
|
|
3739
|
+
subnet = str(spec.get("subnetId") or spec.get("subnet_id") or "").strip()
|
|
3740
|
+
if not platform or not preset:
|
|
3741
|
+
log.error("nebius: refusing to create %r without a platform AND a preset. These pick "
|
|
3742
|
+
"the hardware and the bill; set FRUTEBOX_NEBIUS_POD_SPEC.", label)
|
|
3743
|
+
return False
|
|
3744
|
+
if not (image_family or image_id):
|
|
3745
|
+
log.error("nebius: refusing to create %r without a boot image (imageFamily or "
|
|
3746
|
+
"imageId) — there is no sane default for what the machine boots.", label)
|
|
3747
|
+
return False
|
|
3748
|
+
if not subnet:
|
|
3749
|
+
log.error("nebius: refusing to create %r without a subnetId — a guessed subnet is a "
|
|
3750
|
+
"machine on the wrong network, or an unreachable one.", label)
|
|
3751
|
+
return False
|
|
3752
|
+
|
|
3753
|
+
# THE EU CHECK IS BEFORE THE CREATE, not after and not alongside. See `_assert_eu_project`.
|
|
3754
|
+
if not await self._assert_eu_project(project):
|
|
3755
|
+
return False
|
|
3756
|
+
|
|
3757
|
+
# `managedDisk` IS A RESOURCE, NOT A SPEC, and only a live call could say so. The published
|
|
3758
|
+
# OpenAPI flattens `AttachedDiskSpec.managedDisk` to `any`, so the fields went straight in —
|
|
3759
|
+
# and the vendor answered (measured 2026-10-18):
|
|
3760
|
+
# spec.boot_disk.managed_disk.name: value is required
|
|
3761
|
+
# spec.boot_disk.managed_disk.spec: value is required
|
|
3762
|
+
# i.e. it wants a NAMED disk wrapping its own spec, the same metadata/spec shape every
|
|
3763
|
+
# other Nebius resource has. Named after the instance so a stray disk is traceable to the
|
|
3764
|
+
# thing that made it.
|
|
3765
|
+
disk_spec: Dict[str, object] = {
|
|
3766
|
+
"type": str(spec.get("diskType") or spec.get("disk_type") or "NETWORK_SSD"),
|
|
3767
|
+
"sizeGibibytes": str(int(spec.get("diskGib") or spec.get("disk_gib") or 128)),
|
|
3768
|
+
}
|
|
3769
|
+
if image_id:
|
|
3770
|
+
disk_spec["sourceImageId"] = image_id
|
|
3771
|
+
else:
|
|
3772
|
+
disk_spec["sourceImageFamily"] = {"imageFamily": image_family}
|
|
3773
|
+
disk: Dict[str, object] = {"name": f"{label}-boot", "spec": disk_spec}
|
|
3774
|
+
|
|
3775
|
+
body: Dict[str, object] = {
|
|
3776
|
+
"metadata": {
|
|
3777
|
+
"parentId": project,
|
|
3778
|
+
# The CALLER's label, never the spec's. A provider that lets the spec's name
|
|
3779
|
+
# through is the 2026-08-21 billing bug reintroduced: the wake cannot find what it
|
|
3780
|
+
# started. Nebius spells the label `metadata.name`.
|
|
3781
|
+
"name": label,
|
|
3782
|
+
},
|
|
3783
|
+
"spec": {
|
|
3784
|
+
"resources": {"platform": platform, "preset": preset},
|
|
3785
|
+
# managedDisk, so the vendor deletes it with the instance. See the docstring.
|
|
3786
|
+
"bootDisk": {"attachMode": "READ_WRITE", "managedDisk": disk},
|
|
3787
|
+
"networkInterfaces": [{"name": "eth0", "subnetId": subnet,
|
|
3788
|
+
"ipAddress": {}}],
|
|
3789
|
+
},
|
|
3790
|
+
}
|
|
3791
|
+
if spec.get("cloudInitUserData") or spec.get("cloud_init_user_data"):
|
|
3792
|
+
body["spec"]["cloudInitUserData"] = str( # type: ignore[index]
|
|
3793
|
+
spec.get("cloudInitUserData") or spec.get("cloud_init_user_data"))
|
|
3794
|
+
if spec.get("publicIp") or spec.get("public_ip"):
|
|
3795
|
+
# An EXPLICIT opt-in. A public address is a cost and an exposure, and the node reaches
|
|
3796
|
+
# the gateway outbound, so nothing here needs one by default.
|
|
3797
|
+
body["spec"]["networkInterfaces"][0]["publicIpAddress"] = {} # type: ignore[index]
|
|
3798
|
+
if spec.get("serviceAccountId"):
|
|
3799
|
+
body["spec"]["serviceAccountId"] = str(spec["serviceAccountId"]) # type: ignore[index]
|
|
3800
|
+
|
|
3801
|
+
headers = await self._headers()
|
|
3802
|
+
if headers is None:
|
|
3803
|
+
return False
|
|
3804
|
+
try:
|
|
3805
|
+
async with httpx.AsyncClient(timeout=90) as c:
|
|
3806
|
+
r = await c.post(f"{self._BASE}{NEBIUS_INSTANCES_PATH}", headers=headers, json=body)
|
|
3807
|
+
except Exception as e:
|
|
3808
|
+
# AN AMBIGUOUS CREATE IS THE WORST OUTCOME IN THIS FILE — the 2026-09-17 orphan was a
|
|
3809
|
+
# create that timed out, was concluded not to have happened, and was honoured
|
|
3810
|
+
# afterwards. So this says so loudly enough for the reaper to be run.
|
|
3811
|
+
log.error("nebius: create %r FAILED IN FLIGHT (%s). An instance may exist that nothing "
|
|
3812
|
+
"recorded — list the project before retrying.", label, type(e).__name__)
|
|
3813
|
+
return False
|
|
3814
|
+
if r.status_code >= 300:
|
|
3815
|
+
log.error("nebius: create %r HTTP %s", label, r.status_code)
|
|
3816
|
+
return False
|
|
3817
|
+
return True
|
|
3818
|
+
|
|
3819
|
+
async def destroy(self, instance_id: str) -> bool:
|
|
3820
|
+
"""Terminate. 404 counts as success — already gone is the state we wanted.
|
|
3821
|
+
|
|
3822
|
+
TERMINATE NEVER STOP: this calls DELETE, and `/compute/v1/instances/stop` is never called
|
|
3823
|
+
from this module at all. A stopped Nebius instance keeps its disks and keeps billing, which
|
|
3824
|
+
is the same trap as a stopped RunPod pod keeping its slot.
|
|
3825
|
+
|
|
3826
|
+
NO VOLUME BOOKKEEPING IS NEEDED HERE, and that is a property of the create rather than an
|
|
3827
|
+
omission: `create` always attaches a VM-MANAGED boot disk, which the vendor deletes with
|
|
3828
|
+
the instance. A disk attached by hand, or an `existingDisk` someone configures later, would
|
|
3829
|
+
survive. There is no volume sweep in this repo to fall back on (`volumes.py` is pure
|
|
3830
|
+
policy, not an inventory), but the spec path that could introduce a standalone disk is not
|
|
3831
|
+
reachable from `create` — it always attaches a managed one — so this is a gap only for a
|
|
3832
|
+
disk somebody attaches by hand.
|
|
3833
|
+
"""
|
|
3834
|
+
headers = await self._headers()
|
|
3835
|
+
if headers is None:
|
|
3836
|
+
return False
|
|
3837
|
+
try:
|
|
3838
|
+
async with httpx.AsyncClient(timeout=60) as c:
|
|
3839
|
+
r = await c.delete(
|
|
3840
|
+
f"{self._BASE}{NEBIUS_INSTANCES_PATH}/{_urlquote(str(instance_id))}",
|
|
3841
|
+
headers=headers)
|
|
3842
|
+
except Exception as e:
|
|
3843
|
+
log.error("nebius: delete %s failed (%s) — reporting False so the caller looks, "
|
|
3844
|
+
"rather than a teardown that did not happen", instance_id, type(e).__name__)
|
|
3845
|
+
return False
|
|
3846
|
+
return r.status_code < 300 or r.status_code == 404
|
|
3847
|
+
|
|
3848
|
+
|
|
3849
|
+
# ---- Alibaba Cloud ECS, INTERNATIONAL only (28 §A, 2026-10-12) -----------------------------
|
|
3850
|
+
#
|
|
3851
|
+
# PRC-INCORPORATED, SO IT IS REGION-LOCKED BY CONSTRUCTION. The seed row carries
|
|
3852
|
+
# `region_locked: true` and `regions: [ap-southeast]`, and `providers.eligible()` therefore excludes
|
|
3853
|
+
# it from every EU/UK/US launch AND from every launch whose region is unstated. That second half is
|
|
3854
|
+
# the one that needed fixing: `eligible()` skips region narrowing entirely when no region is given
|
|
3855
|
+
# (the warm pool reconciles that way by design), so `regions` alone left this provider eligible for
|
|
3856
|
+
# any unlabelled job. "Unstated" is not "permitted" — see `ProviderRow.region_locked`.
|
|
3857
|
+
#
|
|
3858
|
+
# TARGET IS ALIBABA CLOUD INTERNATIONAL, not mainland China: `ecs.ap-southeast-1.aliyuncs.com`
|
|
3859
|
+
# (Singapore), verified against the regional endpoint list 2026-10-12. The credential is a RAM
|
|
3860
|
+
# user's, never the root account's, and it is read through `_provider_credential` like every other.
|
|
3861
|
+
#
|
|
3862
|
+
# TERMINATE, NEVER STOP — AND THE API MAKES THAT A DELIBERATE CHOICE. `DeleteInstance` with
|
|
3863
|
+
# `Force=false` REQUIRES the instance to be in the Stopped state first, and a stopped-but-unreleased
|
|
3864
|
+
# ECS instance still bills for its disks. So this adapter always passes `Force=true`, which releases
|
|
3865
|
+
# a RUNNING instance directly and never calls `StopInstance` at all — there is no code path here
|
|
3866
|
+
# that stops anything, and `test_alibaba_provider.py` asserts the string is absent from the module.
|
|
3867
|
+
# Alibaba's own wording: "After an instance is released, all physical resources used by the instance
|
|
3868
|
+
# are reclaimed."
|
|
3869
|
+
#
|
|
3870
|
+
# TWO BILLING LEAKS THE DOCS NAME, both closed at create time rather than hoped about:
|
|
3871
|
+
# * a disk with `DeleteWithInstance=false` survives the instance as a pay-as-you-go disk, so the
|
|
3872
|
+
# spec's system disk is never allowed to set it false;
|
|
3873
|
+
# * an EIP is NOT released with the instance, so no public IP is allocated
|
|
3874
|
+
# (`InternetMaxBandwidthOut=0`) and there is nothing left behind to release separately.
|
|
3875
|
+
#
|
|
3876
|
+
# THE SPEC COMES FROM THE OPERATOR, like `RUNPOD_POD_SPEC`: image, instance type, security group and
|
|
3877
|
+
# vSwitch are the decisions that cost money and pick hardware, and a provider that guesses them
|
|
3878
|
+
# deploys the wrong machine and bills for it (the 2026-09-24 `VAST_POD_SPEC` ruling). `create`
|
|
3879
|
+
# refuses without them rather than inventing a gn-series type.
|
|
3880
|
+
ALIBABA_ENDPOINT = envvar("ALIBABA_ENDPOINT", "ecs.ap-southeast-1.aliyuncs.com").strip()
|
|
3881
|
+
ALIBABA_REGION_ID = envvar("ALIBABA_REGION_ID", "ap-southeast-1").strip()
|
|
3882
|
+
#: ECS API version, from the operation reference (`api-ecs-2014-05-26-*`).
|
|
3883
|
+
ALIBABA_API_VERSION = "2014-05-26"
|
|
3884
|
+
#: SHA-256 of the empty body, which every GET/RPC call here sends. Named because it appears in both
|
|
3885
|
+
#: the canonical request and the `x-acs-content-sha256` header and the two must agree.
|
|
3886
|
+
_EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
3887
|
+
|
|
3888
|
+
|
|
3889
|
+
def _alibaba_spec() -> dict:
|
|
3890
|
+
"""The operator's launch spec as JSON, or `{}` when unset."""
|
|
3891
|
+
raw = (envvar("ALIBABA_ECS_SPEC", "") or "").strip()
|
|
3892
|
+
if not raw:
|
|
3893
|
+
return {}
|
|
3894
|
+
try:
|
|
3895
|
+
spec = json.loads(raw)
|
|
3896
|
+
except Exception:
|
|
3897
|
+
log.error("ALIBABA_ECS_SPEC is not valid JSON — refusing to launch on a half-read spec")
|
|
3898
|
+
return {}
|
|
3899
|
+
return spec if isinstance(spec, dict) else {}
|
|
3900
|
+
|
|
3901
|
+
|
|
3902
|
+
def alibaba_signature(method: str, params: Dict[str, str], *, access_key_id: str,
|
|
3903
|
+
access_key_secret: str, host: str, action: str, nonce: str,
|
|
3904
|
+
date: str) -> Dict[str, str]:
|
|
3905
|
+
"""Sign an RPC-style request with ACS3-HMAC-SHA256 and return the headers to send.
|
|
3906
|
+
|
|
3907
|
+
A PURE FUNCTION, deliberately: it takes the credential rather than reading it, so the signer can
|
|
3908
|
+
be tested against a known vector without a key in the environment and without a network call.
|
|
3909
|
+
The algorithm is from Alibaba's own V3 request-structure reference (read 2026-10-12):
|
|
3910
|
+
|
|
3911
|
+
canonical request = METHOD \n CanonicalURI \n CanonicalQueryString \n CanonicalHeaders \n
|
|
3912
|
+
SignedHeaders \n HashedPayload
|
|
3913
|
+
string to sign = "ACS3-HMAC-SHA256" \n hex(sha256(canonical request))
|
|
3914
|
+
signature = hex(hmac_sha256(secret, string to sign))
|
|
3915
|
+
Authorization = ACS3-HMAC-SHA256 Credential=<id>,SignedHeaders=<h>,Signature=<sig>
|
|
3916
|
+
|
|
3917
|
+
CanonicalURI is "/" for RPC APIs, which every ECS operation used here is. Query parameters are
|
|
3918
|
+
sorted by name and encoded per RFC 3986 with `*` as %2A, space as %20 and `~` left alone —
|
|
3919
|
+
`quote(safe="-_.~")` gives exactly that, and getting it wrong is a 403 that reads like a bad key.
|
|
3920
|
+
"""
|
|
3921
|
+
canonical_query = "&".join(
|
|
3922
|
+
f"{_urlquote(k, safe='-_.~')}={_urlquote(str(v), safe='-_.~')}"
|
|
3923
|
+
for k, v in sorted(params.items()))
|
|
3924
|
+
headers = {
|
|
3925
|
+
"host": host,
|
|
3926
|
+
"x-acs-action": action,
|
|
3927
|
+
"x-acs-content-sha256": _EMPTY_SHA256,
|
|
3928
|
+
"x-acs-date": date,
|
|
3929
|
+
"x-acs-signature-nonce": nonce,
|
|
3930
|
+
"x-acs-version": ALIBABA_API_VERSION,
|
|
3931
|
+
}
|
|
3932
|
+
signed_names = sorted(headers)
|
|
3933
|
+
canonical_headers = "".join(f"{n}:{headers[n].strip()}\n" for n in signed_names)
|
|
3934
|
+
signed_headers = ";".join(signed_names)
|
|
3935
|
+
canonical_request = "\n".join([
|
|
3936
|
+
method.upper(), "/", canonical_query, canonical_headers, signed_headers, _EMPTY_SHA256,
|
|
3937
|
+
])
|
|
3938
|
+
string_to_sign = "ACS3-HMAC-SHA256\n" + hashlib.sha256(
|
|
3939
|
+
canonical_request.encode()).hexdigest()
|
|
3940
|
+
signature = hmac.new(access_key_secret.encode(), string_to_sign.encode(),
|
|
3941
|
+
hashlib.sha256).hexdigest()
|
|
3942
|
+
return {
|
|
3943
|
+
**headers,
|
|
3944
|
+
"Authorization": (f"ACS3-HMAC-SHA256 Credential={access_key_id},"
|
|
3945
|
+
f"SignedHeaders={signed_headers},Signature={signature}"),
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3948
|
+
|
|
3949
|
+
class AlibabaProvider:
|
|
3950
|
+
"""Alibaba Cloud ECS (gn-series GPU), international regions only.
|
|
3951
|
+
|
|
3952
|
+
`region_locked` in the seed row is what keeps it out of EU/UK/US and unlabelled work; this class
|
|
3953
|
+
does not re-implement that gate, for the reason `providers.eligible` gives — one filter, in one
|
|
3954
|
+
place, or the two spellings drift.
|
|
3955
|
+
"""
|
|
3956
|
+
|
|
3957
|
+
name = "alibaba"
|
|
3958
|
+
|
|
3959
|
+
#: ECS reports `Status` per instance (Running / Stopped / Starting / Stopping / Pending), so this
|
|
3960
|
+
#: provider CAN tell serving from merely existing — and must, because a Stopped instance still
|
|
3961
|
+
#: bills for its disks. Declared True, and both enumerations are implemented below; declaring it
|
|
3962
|
+
#: and not implementing them is what `test_gpuproviders.py`'s contract test refuses.
|
|
3963
|
+
DISTINGUISHES_SERVING = True
|
|
3964
|
+
|
|
3965
|
+
#: Statuses that are NOT serving but ARE still billing. The same distinction Vast's `exited`
|
|
3966
|
+
#: taught: "not serving" and "not billing" are different facts and one list cannot answer both.
|
|
3967
|
+
_NOT_SERVING = frozenset({"stopped", "stopping", "pending", "starting"})
|
|
3968
|
+
|
|
3969
|
+
def _key(self) -> "tuple[str, str]":
|
|
3970
|
+
return (_provider_credential("alibaba", "ALIBABA_CLOUD_ACCESS_KEY_ID"),
|
|
3971
|
+
_provider_credential("alibaba", "ALIBABA_CLOUD_ACCESS_KEY_SECRET"))
|
|
3972
|
+
|
|
3973
|
+
def configured(self) -> bool:
|
|
3974
|
+
"""BOTH halves of the credential AND an operator spec. An access key id with no secret is
|
|
3975
|
+
not a credential, and either one alone reporting `configured: true` is how a provider wins a
|
|
3976
|
+
race it cannot start."""
|
|
3977
|
+
kid, secret = self._key()
|
|
3978
|
+
return bool(kid and secret and _alibaba_spec())
|
|
3979
|
+
|
|
3980
|
+
def _headers(self, action: str, params: Dict[str, str]) -> dict:
|
|
3981
|
+
kid, secret = self._key()
|
|
3982
|
+
return alibaba_signature(
|
|
3983
|
+
"GET", params, access_key_id=kid, access_key_secret=secret,
|
|
3984
|
+
host=ALIBABA_ENDPOINT, action=action,
|
|
3985
|
+
nonce=uuid.uuid4().hex,
|
|
3986
|
+
date=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
|
|
3987
|
+
|
|
3988
|
+
async def _call(self, action: str, params: Dict[str, str]) -> dict:
|
|
3989
|
+
"""One signed RPC call. `{}` on any failure — a provider that cannot be read is skipped by
|
|
3990
|
+
the race, never treated as empty-and-therefore-idle."""
|
|
3991
|
+
kid, secret = self._key()
|
|
3992
|
+
if not (kid and secret):
|
|
3993
|
+
return {}
|
|
3994
|
+
q = {"RegionId": ALIBABA_REGION_ID, **params}
|
|
3995
|
+
headers = self._headers(action, q)
|
|
3996
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
3997
|
+
r = await c.get(f"https://{ALIBABA_ENDPOINT}/", headers=headers, params=q)
|
|
3998
|
+
if r.status_code >= 300:
|
|
3999
|
+
# The STATUS and the action, never the body: an Alibaba error echoes the request,
|
|
4000
|
+
# and this is the kind of line that ends up pasted into an issue.
|
|
4001
|
+
log.warning("alibaba %s: HTTP %s", action, r.status_code)
|
|
4002
|
+
return {}
|
|
4003
|
+
try:
|
|
4004
|
+
return r.json() or {}
|
|
4005
|
+
except Exception:
|
|
4006
|
+
return {}
|
|
4007
|
+
|
|
4008
|
+
async def _listed(self, label: str) -> List[dict]:
|
|
4009
|
+
"""Every instance whose name carries this label, in WHATEVER state.
|
|
4010
|
+
|
|
4011
|
+
Raw and unfiltered, like Vast's `_labelled`: which statuses count is the CALLER's question,
|
|
4012
|
+
and a pre-filtered list is what hid a paid-for instance from the census and the teardown at
|
|
4013
|
+
the same time.
|
|
4014
|
+
"""
|
|
4015
|
+
data = await self._call("DescribeInstances",
|
|
4016
|
+
{"InstanceName": label, "MaxResults": "100"})
|
|
4017
|
+
return list(((data.get("Instances") or {}).get("Instance")) or [])
|
|
4018
|
+
|
|
4019
|
+
async def find(self, label: str) -> List[Instance]:
|
|
4020
|
+
return await self.find_live(label)
|
|
4021
|
+
|
|
4022
|
+
async def find_live(self, label: str) -> List[Instance]:
|
|
4023
|
+
"""CAPACITY: Running only. A Stopped instance serves nothing, and counting it would let it
|
|
4024
|
+
hold the pool's only slot and block its own replacement."""
|
|
4025
|
+
return [Instance(id=str(i.get("InstanceId") or ""), label=label)
|
|
4026
|
+
for i in await self._listed(label)
|
|
4027
|
+
if str(i.get("Status", "")).lower() not in self._NOT_SERVING]
|
|
4028
|
+
|
|
4029
|
+
async def find_all(self, label: str) -> List[Instance]:
|
|
4030
|
+
"""TEARDOWN breadth: everything ECS still lists, Stopped included — because a stopped
|
|
4031
|
+
instance still bills for its disks, which is exactly the leak `destroy` exists to close."""
|
|
4032
|
+
return [Instance(id=str(i.get("InstanceId") or ""), label=label)
|
|
4033
|
+
for i in await self._listed(label)]
|
|
4034
|
+
|
|
4035
|
+
async def create(self, label: str, spec: dict) -> "dict | bool":
|
|
4036
|
+
"""RunInstances. REFUSES without an operator spec rather than inventing hardware."""
|
|
4037
|
+
base = _alibaba_spec()
|
|
4038
|
+
if not base:
|
|
4039
|
+
raise RuntimeError(
|
|
4040
|
+
"alibaba: no launch spec — set FRUTEBOX_ALIBABA_ECS_SPEC to a JSON object with at "
|
|
4041
|
+
"least ImageId, InstanceType, SecurityGroupId and VSwitchId. Refusing to guess an "
|
|
4042
|
+
"instance type for a provider that bills.")
|
|
4043
|
+
kid, secret = self._key()
|
|
4044
|
+
if not (kid and secret):
|
|
4045
|
+
return False
|
|
4046
|
+
params = {
|
|
4047
|
+
"InstanceName": label, # the caller's label, never the spec's
|
|
4048
|
+
"Amount": "1",
|
|
4049
|
+
# NO PUBLIC IP. An EIP is not released with the instance, so allocating one leaves a
|
|
4050
|
+
# billable resource behind that teardown cannot see.
|
|
4051
|
+
"InternetMaxBandwidthOut": "0",
|
|
4052
|
+
**{k: str(v) for k, v in base.items() if k != "InstanceName"},
|
|
4053
|
+
# LAST, so a spec cannot turn it off: a system disk kept past the instance becomes a
|
|
4054
|
+
# pay-as-you-go disk that nothing in this codebase is looking for.
|
|
4055
|
+
"SystemDisk.DeleteWithInstance": "true",
|
|
4056
|
+
}
|
|
4057
|
+
data = await self._call("RunInstances", params)
|
|
4058
|
+
ids = list((data.get("InstanceIdSets") or {}).get("InstanceIdSet") or [])
|
|
4059
|
+
if not ids:
|
|
4060
|
+
return False
|
|
4061
|
+
return {"id": str(ids[0]), "name": label}
|
|
4062
|
+
|
|
4063
|
+
async def destroy(self, instance_id: str) -> bool:
|
|
4064
|
+
"""DeleteInstance with `Force=true` — release a RUNNING instance directly.
|
|
4065
|
+
|
|
4066
|
+
THE FORCE FLAG IS THE WHOLE POINT. With `Force=false` Alibaba requires the instance to be
|
|
4067
|
+
Stopped first, which would mean calling `StopInstance` — and a stopped-but-unreleased ECS
|
|
4068
|
+
instance still bills for its disks. So this never stops anything; it releases, and Alibaba
|
|
4069
|
+
reclaims the physical resources.
|
|
4070
|
+
"""
|
|
4071
|
+
data = await self._call("DeleteInstance",
|
|
4072
|
+
{"InstanceId": str(instance_id), "Force": "true"})
|
|
4073
|
+
# A successful RPC returns a RequestId. `{}` is the failure shape from `_call`.
|
|
4074
|
+
return bool(data.get("RequestId"))
|
|
4075
|
+
|
|
4076
|
+
|
|
4077
|
+
# ---- Phala (the TEE lane's confidential-compute provider, plan 08 / 28 §A3) -----------------
|
|
4078
|
+
#
|
|
4079
|
+
# ITS OWN ADAPTER AND ITS OWN SPEC, never borrowed. The 2026-09-24 ruling on `VAST_POD_SPEC` is the
|
|
4080
|
+
# reason: "a provider that borrows another's spec deploys the wrong machine and bills for it." A TEE
|
|
4081
|
+
# provider borrowing a GPU provider's spec would be that failure with an attestation claim on top.
|
|
4082
|
+
#
|
|
4083
|
+
# THE API SHAPE, now from Phala's own OpenAPI rather than from guesswork (read 2026-10-07, and the base
|
|
4084
|
+
# supplied by the owner the same day). Every one of these is quoted from the spec embedded in
|
|
4085
|
+
# `docs.phala.com/api-reference/cvms/*`:
|
|
4086
|
+
#
|
|
4087
|
+
# POST /api/v1/cvms/provision provision-dstack-app -> caches the compose, returns compose_hash
|
|
4088
|
+
# POST /api/v1/cvms create-cvm-from-provision (the COMMIT) -> requires app_id + compose_hash
|
|
4089
|
+
# GET /api/v1/cvms list-cvms
|
|
4090
|
+
# DELETE /api/v1/cvms/{cvm_id} delete-cvm -> id in the PATH, not the body
|
|
4091
|
+
#
|
|
4092
|
+
# Authentication is the header `X-API-Key`. The earlier revision of this class refused to act because the
|
|
4093
|
+
# paths were unconfirmed; they are confirmed now, so the defaults below are real and no operator
|
|
4094
|
+
# configuration is needed to reach the API. The override survives because the base is the one thing that
|
|
4095
|
+
# has already been wrong twice: the registry row said `api.phala.network` (an API that is not there) and
|
|
4096
|
+
# `cloud.phala.com` is the DASHBOARD. Phala's own SDK calls this override `PHALA_CLOUD_API_PREFIX`.
|
|
4097
|
+
#
|
|
4098
|
+
# `configured()` STILL REQUIRES BOTH a credential and a known shape. That is unchanged on purpose: it is
|
|
4099
|
+
# what stops an operator who blanks the base from having a provider that reports ready and cannot call
|
|
4100
|
+
# anything.
|
|
4101
|
+
PHALA_BASE = envvar("PHALA_API_BASE", "https://cloud-api.phala.com/api/v1").rstrip("/")
|
|
4102
|
+
#: `provision,commit,list,delete` — relative to `PHALA_BASE`, in that order. `{cvm_id}` is substituted.
|
|
4103
|
+
PHALA_PATHS = [p.strip() for p in
|
|
4104
|
+
envvar("PHALA_PATHS", "/cvms/provision,/cvms,/cvms,/cvms/{cvm_id}").split(",")
|
|
4105
|
+
if p.strip()]
|
|
4106
|
+
|
|
4107
|
+
|
|
4108
|
+
class PhalaProvider:
|
|
4109
|
+
"""Phala confidential VMs, through the same `GpuProvider` protocol as every other backend.
|
|
4110
|
+
|
|
4111
|
+
`class: tee` in `registry/providers.yaml`, so §A4's class gate keeps it out of the GPU race whatever
|
|
4112
|
+
the warm-pool allow-list says, and the §16 lane toggle (`FRUTEBOX_TEE_LANE`, default OFF) keeps the
|
|
4113
|
+
whole lane unreachable until an operator asks for it. Attestation does NOT go through our verifier:
|
|
4114
|
+
`tee.DIRECT_NRAS_PROVIDERS` already lists `phala`, so its evidence goes straight to NVIDIA's NRAS.
|
|
4115
|
+
"""
|
|
4116
|
+
|
|
4117
|
+
name = "phala"
|
|
4118
|
+
|
|
4119
|
+
#: Phala answers one question about an instance: is it listed. It cannot distinguish a CVM that is
|
|
4120
|
+
#: SERVING from one that merely exists, so the ambiguity is declared rather than discovered — the
|
|
4121
|
+
#: same declaration DataCrunch makes, and `nodewake` resolves it toward suspected_billing.
|
|
4122
|
+
DISTINGUISHES_SERVING = False
|
|
4123
|
+
|
|
4124
|
+
def _key(self) -> str:
|
|
4125
|
+
return _provider_credential("phala", "PHALA_API_KEY")
|
|
4126
|
+
|
|
4127
|
+
def _shape_known(self) -> bool:
|
|
4128
|
+
"""Is the API shape confirmed? Four paths and a base, or nothing."""
|
|
4129
|
+
return bool(PHALA_BASE) and len(PHALA_PATHS) == 4
|
|
4130
|
+
|
|
4131
|
+
def configured(self) -> bool:
|
|
4132
|
+
"""A credential AND a confirmed API shape. A key alone is not the ability to call anything, and
|
|
4133
|
+
reporting `True` on a key alone is how a provider wins a race it cannot serve."""
|
|
4134
|
+
return bool(self._key()) and self._shape_known()
|
|
4135
|
+
|
|
4136
|
+
def _headers(self) -> dict:
|
|
4137
|
+
return {"X-API-Key": self._key(), "content-type": "application/json"}
|
|
4138
|
+
|
|
4139
|
+
def _path(self, which: str) -> str:
|
|
4140
|
+
order = ("provision", "commit", "list", "delete")
|
|
4141
|
+
return f"{PHALA_BASE}{PHALA_PATHS[order.index(which)]}"
|
|
4142
|
+
|
|
4143
|
+
async def _labelled(self, label: str) -> List[dict]:
|
|
4144
|
+
if not self.configured():
|
|
4145
|
+
return []
|
|
4146
|
+
async with httpx.AsyncClient(timeout=30) as c:
|
|
4147
|
+
r = await c.get(self._path("list"), headers=self._headers())
|
|
4148
|
+
if r.status_code >= 300:
|
|
4149
|
+
return []
|
|
4150
|
+
body = r.json() or {}
|
|
4151
|
+
rows = body if isinstance(body, list) else (body.get("items") or body.get("data") or [])
|
|
4152
|
+
return [i for i in rows if str(i.get("name") or i.get("app_name") or "") == label]
|
|
4153
|
+
|
|
4154
|
+
async def find(self, label: str) -> List[Instance]:
|
|
4155
|
+
"""THE ONE ENUMERATION THIS API GIVES, and deliberately the only one implemented.
|
|
4156
|
+
|
|
4157
|
+
No `find_live` and no `find_all`: `DISTINGUISHES_SERVING = False` says this provider cannot tell a
|
|
4158
|
+
CVM that is SERVING from one that merely exists, and the module-level `find_live`/`find_all` already
|
|
4159
|
+
fall back to this single question for exactly that case. Implementing both here would contradict the
|
|
4160
|
+
declaration — `test_a_provider_that_cannot_answer_BOTH_questions_must_declare_it` catches precisely
|
|
4161
|
+
that, because a declaration the code does not back up tells callers something untrue — and it would
|
|
4162
|
+
duplicate a fallback the dispatch layer owns. DataCrunch has the same shape for the same reason.
|
|
4163
|
+
"""
|
|
4164
|
+
return [{"id": str(i.get("app_id") or i.get("id") or ""), "name": label,
|
|
4165
|
+
"status": str(i.get("status") or "")}
|
|
4166
|
+
for i in await self._labelled(label)]
|
|
4167
|
+
|
|
4168
|
+
async def create(self, label: str, spec: dict) -> "dict | bool":
|
|
4169
|
+
"""Provision, then commit — Phala's two-phase deployment, which is why this is not one POST.
|
|
4170
|
+
|
|
4171
|
+
REFUSES rather than guesses when the API shape is unconfirmed. A create that posts to an invented
|
|
4172
|
+
path either 404s (a confusing failure) or, worse, succeeds against something unintended.
|
|
4173
|
+
"""
|
|
4174
|
+
if not self._shape_known():
|
|
4175
|
+
raise RuntimeError(
|
|
4176
|
+
"phala: API shape not configured — set FRUTEBOX_PHALA_API_BASE and FRUTEBOX_PHALA_PATHS "
|
|
4177
|
+
"(provision,commit,list,delete) from Phala's API reference. Refusing to guess endpoint "
|
|
4178
|
+
"paths for a provider that bills.")
|
|
4179
|
+
if not self._key():
|
|
4180
|
+
return False
|
|
4181
|
+
body = dict(spec or {})
|
|
4182
|
+
body.setdefault("name", label)
|
|
4183
|
+
async with httpx.AsyncClient(timeout=120) as c:
|
|
4184
|
+
pr = await c.post(self._path("provision"), headers=self._headers(), json=body)
|
|
4185
|
+
if pr.status_code >= 300:
|
|
4186
|
+
return False
|
|
4187
|
+
prov = pr.json() or {}
|
|
4188
|
+
# THE COMMIT'S REQUIRED PAIR, per `create-cvm-from-provision`: `app_id` and `compose_hash`.
|
|
4189
|
+
#
|
|
4190
|
+
# `app_env_encrypt_pubkey` is deliberately NOT sent. It is a provision OUTPUT — the key an
|
|
4191
|
+
# operator encrypts environment variables WITH, producing the optional `encrypted_env` — not a
|
|
4192
|
+
# commit field. Sending it would be inventing a parameter the spec does not have. It is carried
|
|
4193
|
+
# back to the caller instead, so whoever adds env encryption has it; this class does not
|
|
4194
|
+
# encrypt anything, and pretending otherwise would be worse than the gap.
|
|
4195
|
+
commit = {"app_id": prov.get("app_id") or body.get("app_id"),
|
|
4196
|
+
"compose_hash": prov.get("compose_hash")}
|
|
4197
|
+
cr = await c.post(self._path("commit"), headers=self._headers(), json=commit)
|
|
4198
|
+
if cr.status_code >= 300:
|
|
4199
|
+
return False
|
|
4200
|
+
made = cr.json() or {}
|
|
4201
|
+
return {"id": str(made.get("id") or made.get("vm_uuid") or made.get("app_id") or ""),
|
|
4202
|
+
"name": label,
|
|
4203
|
+
# For whoever adds environment encryption: this is what it is encrypted WITH.
|
|
4204
|
+
"app_env_encrypt_pubkey": prov.get("app_env_encrypt_pubkey")}
|
|
4205
|
+
|
|
4206
|
+
async def destroy(self, instance_id: str) -> bool:
|
|
4207
|
+
"""`DELETE /cvms/{cvm_id}` — the id is a PATH parameter, not a body field.
|
|
4208
|
+
|
|
4209
|
+
An earlier revision of this sent `{"app_id": ...}` as a DELETE body, which the spec does not
|
|
4210
|
+
describe: it would have deleted nothing and reported success on whatever the base path answered.
|
|
4211
|
+
`destroy_verified` in the dispatch layer is what would have caught it in the end, by re-reading the
|
|
4212
|
+
listing — but a teardown that silently deletes the wrong thing is not a thing to leave to a backstop.
|
|
4213
|
+
"""
|
|
4214
|
+
if not self._shape_known():
|
|
4215
|
+
return False
|
|
4216
|
+
url = self._path("delete").replace("{cvm_id}", str(instance_id))
|
|
4217
|
+
async with httpx.AsyncClient(timeout=60) as c:
|
|
4218
|
+
r = await c.request("DELETE", url, headers=self._headers())
|
|
4219
|
+
return r.status_code < 300 or r.status_code == 404
|
|
4220
|
+
|
|
4221
|
+
|
|
4222
|
+
async def derive_lambda_launch(api_key: Optional[str] = None, *,
|
|
4223
|
+
instance_type: str = "") -> Dict[str, str]:
|
|
4224
|
+
"""28 §A5 — discover Lambda's account-scoped launch details FROM the key.
|
|
4225
|
+
|
|
4226
|
+
OPTION 1 SHAPE (owner ruling 2026-09-22), the same as RunPod's: the operator makes the
|
|
4227
|
+
decision that costs money — here `LAMBDA_INSTANCE_TYPE`, which `create` already refuses to
|
|
4228
|
+
launch without — and the code discovers the account-scoped consequences of it. This NEVER
|
|
4229
|
+
chooses an instance type.
|
|
4230
|
+
|
|
4231
|
+
WHAT IT FIXES. `create` falls back to `region_name="us-west-1"` when `LAMBDA_REGION` is
|
|
4232
|
+
unset. That is the same defect class as pairing volume X's id with volume Y's datacenter:
|
|
4233
|
+
an instance type that has capacity only in `us-east-1` is launched into `us-west-1` and
|
|
4234
|
+
fails — and a region and a list of SSH key names are account RESOURCES, exactly the thing
|
|
4235
|
+
§A5 says a person should not be copying out of a console.
|
|
4236
|
+
|
|
4237
|
+
Returns {} rather than raising: a missing derivation is a launch that proceeds on whatever
|
|
4238
|
+
the operator did set, not a reason to refuse to provision.
|
|
4239
|
+
"""
|
|
4240
|
+
key = api_key or _provider_credential("lambda", "LAMBDA_API_KEY")
|
|
4241
|
+
if not key or not instance_type:
|
|
4242
|
+
return {}
|
|
4243
|
+
base, headers = LambdaProvider._BASE, {"Authorization": f"Bearer {key}"}
|
|
4244
|
+
out: Dict[str, str] = {}
|
|
4245
|
+
try:
|
|
4246
|
+
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
4247
|
+
types = await client.get(f"{base}/instance-types", headers=headers)
|
|
4248
|
+
keys = await client.get(f"{base}/ssh-keys", headers=headers)
|
|
4249
|
+
except Exception:
|
|
4250
|
+
return {}
|
|
4251
|
+
|
|
4252
|
+
# Shapes are read DEFENSIVELY — every lookup tolerates a missing key and an unexpected
|
|
4253
|
+
# container — because this has not been run against a live Lambda account and a wrong guess
|
|
4254
|
+
# here must degrade to {} rather than to a confident wrong region.
|
|
4255
|
+
try:
|
|
4256
|
+
if types.status_code == 200:
|
|
4257
|
+
data = (types.json() or {}).get("data") or {}
|
|
4258
|
+
entry = data.get(instance_type) if isinstance(data, dict) else None
|
|
4259
|
+
regions = (entry or {}).get("regions_with_capacity_available") or []
|
|
4260
|
+
names = [str(r.get("name")) for r in regions if isinstance(r, dict) and r.get("name")]
|
|
4261
|
+
if names:
|
|
4262
|
+
# First with capacity. Not a cost choice — Lambda prices an instance type the
|
|
4263
|
+
# same everywhere; this only answers "where can it actually start".
|
|
4264
|
+
out["LAMBDA_REGION"] = names[0]
|
|
4265
|
+
except Exception:
|
|
4266
|
+
pass
|
|
4267
|
+
try:
|
|
4268
|
+
if keys.status_code == 200:
|
|
4269
|
+
rows = (keys.json() or {}).get("data") or []
|
|
4270
|
+
names = [str(k.get("name")) for k in rows if isinstance(k, dict) and k.get("name")]
|
|
4271
|
+
if names:
|
|
4272
|
+
out["LAMBDA_SSH_KEYS"] = ",".join(names)
|
|
4273
|
+
except Exception:
|
|
4274
|
+
pass
|
|
4275
|
+
return out
|
|
4276
|
+
|
|
4277
|
+
|
|
4278
|
+
async def derive_runpod_volume(api_key: Optional[str] = None, *,
|
|
4279
|
+
volume_id: str = "") -> Dict[str, str]:
|
|
4280
|
+
"""28 §A5 — discover RunPod's account-scoped volume ids FROM the key.
|
|
4281
|
+
|
|
4282
|
+
The owner supplies a credential; the code discovers the rest. A network volume id and its
|
|
4283
|
+
datacenter are account RESOURCES, not secrets, and asking a person to copy them out of a
|
|
4284
|
+
console is how they end up wrong — pointing a pod at a volume in another region fails at
|
|
4285
|
+
launch, after the GPU is already billing.
|
|
4286
|
+
|
|
4287
|
+
Lives here because this is the single RunPod door (KB §0): every RunPod call in the codebase
|
|
4288
|
+
goes through this module, and `tests/test_gpu_single_door.py` fails if one appears
|
|
4289
|
+
elsewhere. Returns {} rather than raising — a missing volume is a slower cold start, not a
|
|
4290
|
+
reason to refuse to provision.
|
|
4291
|
+
|
|
4292
|
+
PASS `volume_id` TO ASK ABOUT ONE SPECIFIC VOLUME. Without it this guesses — "seed" in the
|
|
4293
|
+
name, else the largest — which is the right guess when nothing is known and the WRONG one
|
|
4294
|
+
when the operator has already named a volume. Deriving a datacenter by heuristic and pairing
|
|
4295
|
+
it with an id the operator chose is how volume X ends up pinned to volume Y's datacenter: a
|
|
4296
|
+
launch that fails after the GPU is already billing. Asked about a specific volume, this
|
|
4297
|
+
returns that volume's own datacenter or nothing at all.
|
|
4298
|
+
"""
|
|
4299
|
+
key = api_key or _provider_credential("runpod", "RUNPOD_API_KEY")
|
|
4300
|
+
if not key:
|
|
4301
|
+
return {}
|
|
4302
|
+
try:
|
|
4303
|
+
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
4304
|
+
r = await client.get(
|
|
4305
|
+
"https://rest.runpod.io/v1/networkvolumes",
|
|
4306
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
4307
|
+
)
|
|
4308
|
+
if r.status_code != 200:
|
|
4309
|
+
return {}
|
|
4310
|
+
payload = r.json()
|
|
4311
|
+
except Exception:
|
|
4312
|
+
return {}
|
|
4313
|
+
|
|
4314
|
+
volumes = payload if isinstance(payload, list) else (payload.get("data") or [])
|
|
4315
|
+
if not volumes:
|
|
4316
|
+
return {}
|
|
4317
|
+
|
|
4318
|
+
# The seeded-weights volume is the one worth using: it is what turns a ~94-minute cold
|
|
4319
|
+
# download into a ~90-second load. Prefer it by name, else fall back to the largest, which is
|
|
4320
|
+
# the only other defensible guess about which volume holds model weights.
|
|
4321
|
+
if volume_id:
|
|
4322
|
+
match = [v for v in volumes if str(v.get("id") or "") == volume_id]
|
|
4323
|
+
if not match:
|
|
4324
|
+
# The named volume is not in this account. Say nothing rather than guess: a wrong
|
|
4325
|
+
# datacenter here is a failed launch, and silence leaves the API to infer it.
|
|
4326
|
+
return {}
|
|
4327
|
+
chosen = match[0]
|
|
4328
|
+
else:
|
|
4329
|
+
seeded = [v for v in volumes if "seed" in str(v.get("name", "")).lower()]
|
|
4330
|
+
chosen = (seeded or sorted(volumes, key=lambda v: int(v.get("size") or 0), reverse=True))[0]
|
|
4331
|
+
out = {}
|
|
4332
|
+
if chosen.get("id"):
|
|
4333
|
+
out["RUNPOD_NETWORK_VOLUME_ID"] = str(chosen["id"])
|
|
4334
|
+
if chosen.get("dataCenterId"):
|
|
4335
|
+
out["RUNPOD_VOLUME_DATACENTER"] = str(chosen["dataCenterId"])
|
|
4336
|
+
return out
|
|
4337
|
+
|
|
4338
|
+
|
|
4339
|
+
PROVIDERS: Dict[str, GpuProvider] = {
|
|
4340
|
+
# Phala is `class: tee` in `registry/providers.yaml`, so §A4's class gate keeps it out of the GPU
|
|
4341
|
+
# race whatever the warm-pool allow-list says, and the §16 lane toggle keeps the lane unreachable
|
|
4342
|
+
# by default. It is registered here so the capability query (`providers doctor`) can REPORT it: a
|
|
4343
|
+
# provider missing from the registry does not report as broken, it does not report at all.
|
|
4344
|
+
p.name: p for p in (RunPodProvider(), VastProvider(), LambdaProvider(),
|
|
4345
|
+
DataCrunchProvider(), PhalaProvider(), NebiusProvider(),
|
|
4346
|
+
# Registered so `providers doctor` can REPORT it. Its residency fence is the
|
|
4347
|
+
# seed row's `region_locked`, enforced in `providers.eligible` — not here,
|
|
4348
|
+
# and not twice.
|
|
4349
|
+
AlibabaProvider()) # type: ignore[misc]
|
|
4350
|
+
}
|
|
4351
|
+
|
|
4352
|
+
|
|
4353
|
+
def configured_providers() -> List[str]:
|
|
4354
|
+
"""Which providers this deployment can actually use. For ops and for honest errors."""
|
|
4355
|
+
return [n for n, p in PROVIDERS.items() if p.configured()]
|
|
4356
|
+
|
|
4357
|
+
|
|
4358
|
+
# ---- ops CLI — so no shell script ever hand-rolls a RunPod call again ----------------------
|
|
4359
|
+
#
|
|
4360
|
+
# `scripts/bench_run.sh` used to carry its own curl to REST + a GraphQL podTerminate — an
|
|
4361
|
+
# UNVERIFIED terminate, the exact §3 wrong pattern, living in an ops script where no test looks.
|
|
4362
|
+
# Usage: RUNPOD_API_KEY=... python -m frutebox.gpuproviders <list|stop-label LABEL [PROVIDER]|terminate POD_ID>
|
|
4363
|
+
|
|
4364
|
+
def _cli() -> int:
|
|
4365
|
+
import asyncio as _a
|
|
4366
|
+
import sys
|
|
4367
|
+
|
|
4368
|
+
rp = PROVIDERS.get("runpod")
|
|
4369
|
+
cmd = sys.argv[1] if len(sys.argv) > 1 else "list"
|
|
4370
|
+
if cmd == "list":
|
|
4371
|
+
# `_list_pods_or_none`, NOT `list_pods`. The difference is the whole point of this command.
|
|
4372
|
+
#
|
|
4373
|
+
# OBSERVED 2026-09-28 from the backend lane: with no resolvable credential this printed
|
|
4374
|
+
# "0 pod(s)" and exited 0. That is the reading an operator takes to mean "the account is
|
|
4375
|
+
# clean, nothing is billing" — and it is produced by the one condition under which we know
|
|
4376
|
+
# nothing at all. `list_pods` flattens a transport or auth failure to `[]` by design
|
|
4377
|
+
# (right for planning, where an empty answer means "plan for none"); a human asking what is
|
|
4378
|
+
# on the account must be told the ask failed.
|
|
4379
|
+
pods = _a.run(rp._list_pods_or_none())
|
|
4380
|
+
if pods is None:
|
|
4381
|
+
print("CANNOT ENUMERATE: no resolvable credential or the provider call failed. "
|
|
4382
|
+
"This is NOT 'no pods' — pods may be running and BILLING. Check the provider "
|
|
4383
|
+
"console.", file=sys.stderr)
|
|
4384
|
+
return 3
|
|
4385
|
+
for p in pods:
|
|
4386
|
+
print(f"{p.get('id')} {p.get('name')} {p.get('desiredStatus')} "
|
|
4387
|
+
f"runtime={'yes' if p.get('runtime') else 'no'}")
|
|
4388
|
+
print(f"{len(pods)} pod(s)")
|
|
4389
|
+
return 0
|
|
4390
|
+
if cmd == "stop-label" and len(sys.argv) > 2:
|
|
4391
|
+
# OPTIONAL PROVIDER, defaulting to runpod. Hand-terminating by label is what an operator
|
|
4392
|
+
# is told to do when the wake path reports a suspected-billing instance
|
|
4393
|
+
# (`nodewake._abandon_unconfirmed`), and that instance is on whichever provider won the
|
|
4394
|
+
# race — which since merit order shipped is routinely not RunPod. A repair instruction
|
|
4395
|
+
# that can only reach one provider is not a repair instruction.
|
|
4396
|
+
target = PROVIDERS.get(sys.argv[3]) if len(sys.argv) > 3 else rp
|
|
4397
|
+
if target is None:
|
|
4398
|
+
print(f"unknown provider {sys.argv[3]!r}; known: {', '.join(sorted(PROVIDERS))}",
|
|
4399
|
+
file=sys.stderr)
|
|
4400
|
+
return 2
|
|
4401
|
+
res = _a.run(stop(target, sys.argv[2]))
|
|
4402
|
+
print(res)
|
|
4403
|
+
return 0 if res.get("ok") else 1
|
|
4404
|
+
if cmd == "terminate" and len(sys.argv) > 2:
|
|
4405
|
+
return 0 if _a.run(rp.terminate(sys.argv[2])) else 1
|
|
4406
|
+
if cmd == "stop-all":
|
|
4407
|
+
# OURS ONLY. This used to terminate EVERY pod on the account, and the account is SHARED
|
|
4408
|
+
# with another project (owner, 2026-09-28) — so the bench script's cost-safety trap would
|
|
4409
|
+
# have destroyed a third party's running work, from a `trap ... EXIT`, with no confirmation
|
|
4410
|
+
# and no way to undo it. The reaper elsewhere in this file already refuses to touch names
|
|
4411
|
+
# outside our scope for exactly this reason; this path predated that rule and never got it.
|
|
4412
|
+
#
|
|
4413
|
+
# `--everything` still exists because a genuine "kill absolutely all billing" exists as an
|
|
4414
|
+
# intent — but it has to be ASKED FOR, out loud, and it prints what it is about to do.
|
|
4415
|
+
everything = "--everything" in sys.argv[2:]
|
|
4416
|
+
prefix = "frutebox-"
|
|
4417
|
+
|
|
4418
|
+
# AND THE LISTING ITSELF MUST BE HONEST. Built on `list_pods`, an unauthenticated or failed
|
|
4419
|
+
# listing returned `[]`: the loop terminated nothing, the function returned True and the trap
|
|
4420
|
+
# exited 0 — a successful teardown reported for an account it could not see. Scoping the
|
|
4421
|
+
# sweep and knowing whether you can see the account are two different safety properties, and
|
|
4422
|
+
# this command needs both: one bounds what it kills, the other bounds what it CLAIMS.
|
|
4423
|
+
async def _all():
|
|
4424
|
+
pods = await rp._list_pods_or_none()
|
|
4425
|
+
if pods is None:
|
|
4426
|
+
log.error("stop-all CANNOT LIST the account — nothing was terminated and NOTHING "
|
|
4427
|
+
"IS PROVEN STOPPED. Pods may still be BILLING.")
|
|
4428
|
+
return False
|
|
4429
|
+
ok = True
|
|
4430
|
+
mine = [p for p in pods
|
|
4431
|
+
if everything or str(p.get("name") or "").startswith(prefix)]
|
|
4432
|
+
skipped = len(pods) - len(mine)
|
|
4433
|
+
if skipped:
|
|
4434
|
+
print(f"leaving {skipped} pod(s) alone — not named {prefix}* "
|
|
4435
|
+
f"(shared account; use --everything to override)")
|
|
4436
|
+
for pod in mine:
|
|
4437
|
+
pid = str(pod.get("id") or "")
|
|
4438
|
+
if pid:
|
|
4439
|
+
print(f"terminating {pid} {pod.get('name')!r}")
|
|
4440
|
+
ok = await rp.terminate(pid) and ok
|
|
4441
|
+
return ok
|
|
4442
|
+
return 0 if _a.run(_all()) else 1
|
|
4443
|
+
if cmd == "availability":
|
|
4444
|
+
# WHICH CARDS CAN ACTUALLY BE RENTED RIGHT NOW, and what they cost.
|
|
4445
|
+
#
|
|
4446
|
+
# Without this, finding capacity means ATTEMPTING a create per card, and every miss costs
|
|
4447
|
+
# ~75s because RunPod's "no instances available" reply is ambiguous (a pod may have been
|
|
4448
|
+
# created) so the provider reconciles against the live pod list before giving up. Eight
|
|
4449
|
+
# combinations is ~10 minutes, which does not fit inside Cloud Run's 300s request cap —
|
|
4450
|
+
# that is what made the fallback walk unusable on 2026-09-28.
|
|
4451
|
+
#
|
|
4452
|
+
# `lowestPrice` comes back null for a GPU with no stock, so this is a real availability
|
|
4453
|
+
# signal and not just a price list. Read-only: it rents nothing.
|
|
4454
|
+
async def _avail():
|
|
4455
|
+
types = await rp.gpu_prices()
|
|
4456
|
+
if not types:
|
|
4457
|
+
print("no answer from the provider (credential missing or unreachable)",
|
|
4458
|
+
file=sys.stderr)
|
|
4459
|
+
return 1
|
|
4460
|
+
rows = []
|
|
4461
|
+
for t in types:
|
|
4462
|
+
lp = t.get("lowestPrice") or {}
|
|
4463
|
+
rows.append((
|
|
4464
|
+
t.get("displayName") or t.get("id"),
|
|
4465
|
+
t.get("memoryInGb"),
|
|
4466
|
+
t.get("securePrice"),
|
|
4467
|
+
t.get("communityPrice"),
|
|
4468
|
+
lp.get("uninterruptablePrice"),
|
|
4469
|
+
lp.get("minimumBidPrice"),
|
|
4470
|
+
))
|
|
4471
|
+
rows.sort(key=lambda r: (r[4] is None, r[4] if r[4] is not None else 9e9))
|
|
4472
|
+
print(f"{'gpu':<28}{'vram':>6}{'secure':>9}{'comm':>9}"
|
|
4473
|
+
f"{'on-demand':>11}{'spot':>9} available")
|
|
4474
|
+
for name, vram, sec, comm, od, bid in rows:
|
|
4475
|
+
avail = "YES" if (od or bid) else "no stock"
|
|
4476
|
+
def f(x): return "-" if x is None else f"{x:g}"
|
|
4477
|
+
print(f"{str(name):<28}{str(vram or '-'):>6}{f(sec):>9}{f(comm):>9}"
|
|
4478
|
+
f"{f(od):>11}{f(bid):>9} {avail}")
|
|
4479
|
+
return 0
|
|
4480
|
+
return _a.run(_avail())
|
|
4481
|
+
|
|
4482
|
+
print("usage: python -m frutebox.gpuproviders "
|
|
4483
|
+
"<list|availability|stop-label LABEL [PROVIDER]|terminate POD_ID|stop-all [--everything]>",
|
|
4484
|
+
file=sys.stderr)
|
|
4485
|
+
return 2
|
|
4486
|
+
|
|
4487
|
+
|
|
4488
|
+
if __name__ == "__main__":
|
|
4489
|
+
raise SystemExit(_cli())
|