gipper 0.3.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.
- gipper/__init__.py +2 -0
- gipper/billing.py +85 -0
- gipper/bootstrap.sh +25 -0
- gipper/cli.py +216 -0
- gipper/config.py +91 -0
- gipper/costs.py +125 -0
- gipper/cuda_probe.py +78 -0
- gipper/history.py +232 -0
- gipper/http.py +40 -0
- gipper/job.py +153 -0
- gipper/latency.py +50 -0
- gipper/ledger.py +68 -0
- gipper/lifecycle.py +60 -0
- gipper/market.py +179 -0
- gipper/network_probe.py +78 -0
- gipper/placement.py +211 -0
- gipper/providers/__init__.py +50 -0
- gipper/providers/runpod.py +225 -0
- gipper/providers/shadeform.py +143 -0
- gipper/providers/vast.py +170 -0
- gipper/reap.py +95 -0
- gipper/run.py +358 -0
- gipper/runpod/__init__.py +2 -0
- gipper/runpod/api.py +10 -0
- gipper/runpod/run.py +24 -0
- gipper/ssh.py +82 -0
- gipper-0.3.0.dist-info/METADATA +275 -0
- gipper-0.3.0.dist-info/RECORD +32 -0
- gipper-0.3.0.dist-info/WHEEL +5 -0
- gipper-0.3.0.dist-info/entry_points.txt +2 -0
- gipper-0.3.0.dist-info/licenses/LICENSE +21 -0
- gipper-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Runpod: GraphQL for prices, stock and the account, REST for pods and network
|
|
2
|
+
volumes. Containers with ssh as root; TCP and HTTP ports only ("Pods do not
|
|
3
|
+
support UDP connections", docs.runpod.io/pods/configuration/expose-ports).
|
|
4
|
+
|
|
5
|
+
Regions: stock is asked per country (`lowestPrice(countryCode:)`), and the pod is
|
|
6
|
+
created with `countryCodes`, so a 4090 that only exists in Iceland is not rented
|
|
7
|
+
for a job that must stay in the US.
|
|
8
|
+
"""
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
from gipper.config import settings
|
|
14
|
+
from gipper.http import ProviderError, request
|
|
15
|
+
from gipper.market import Offer
|
|
16
|
+
from gipper.providers import BadHost, Provider, Unavailable
|
|
17
|
+
from gipper.ssh import Remote
|
|
18
|
+
|
|
19
|
+
GRAPHQL = "https://api.runpod.io/graphql"
|
|
20
|
+
REST = "https://rest.runpod.io/v1"
|
|
21
|
+
RunpodError = ProviderError
|
|
22
|
+
# What POST /pods accepts for allowedCudaVersions (REST v1 schema, 2026-10)
|
|
23
|
+
CUDA_VERSIONS = ("13.0", "12.9", "12.8", "12.7", "12.6", "12.5", "12.4", "12.3", "12.2", "12.1", "12.0", "11.8")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Api:
|
|
27
|
+
def __init__(self, key):
|
|
28
|
+
self._key = key
|
|
29
|
+
|
|
30
|
+
def _req(self, method, url, body=None):
|
|
31
|
+
return request(method, url, body, headers={"Authorization": f"Bearer {self._key}"})
|
|
32
|
+
|
|
33
|
+
def gql(self, query):
|
|
34
|
+
d = self._req("POST", GRAPHQL, {"query": query})
|
|
35
|
+
if d.get("errors"):
|
|
36
|
+
raise ProviderError(str(d["errors"])[:500])
|
|
37
|
+
return d["data"]
|
|
38
|
+
|
|
39
|
+
def account(self):
|
|
40
|
+
return self.gql("query { myself { clientBalance currentSpendPerHr spendLimit } }")["myself"]
|
|
41
|
+
|
|
42
|
+
def gpu_types(self, countries=()):
|
|
43
|
+
"""Every GPU type with prices; stock overall (`lowestPrice`) and, per country
|
|
44
|
+
and cloud, as `stock_<CC>_<community|secure>`."""
|
|
45
|
+
extra = "".join(
|
|
46
|
+
f' stock_{cc}_{c}: lowestPrice(input:{{gpuCount:1, countryCode:"{cc}", secureCloud:{str(c == "secure").lower()}}}){{ stockStatus }}'
|
|
47
|
+
for cc in countries for c in ("community", "secure"))
|
|
48
|
+
q = ("query { gpuTypes { id displayName memoryInGb secureCloud communityCloud securePrice communityPrice "
|
|
49
|
+
f"lowestPrice(input:{{gpuCount:1}}){{ uninterruptablePrice stockStatus }}{extra} }} }}")
|
|
50
|
+
return self.gql(q)["gpuTypes"]
|
|
51
|
+
|
|
52
|
+
def create_pod(self, spec):
|
|
53
|
+
return self._req("POST", f"{REST}/pods", spec)
|
|
54
|
+
|
|
55
|
+
def get_pod(self, pod_id):
|
|
56
|
+
return self._req("GET", f"{REST}/pods/{pod_id}")
|
|
57
|
+
|
|
58
|
+
def list_pods(self):
|
|
59
|
+
d = self._req("GET", f"{REST}/pods")
|
|
60
|
+
return d if isinstance(d, list) else d.get("pods", d.get("data", []))
|
|
61
|
+
|
|
62
|
+
def terminate_pod(self, pod_id):
|
|
63
|
+
return self._req("DELETE", f"{REST}/pods/{pod_id}")
|
|
64
|
+
|
|
65
|
+
def volumes(self):
|
|
66
|
+
d = self._req("GET", f"{REST}/networkvolumes")
|
|
67
|
+
return d if isinstance(d, list) else d.get("networkVolumes", d.get("data", []))
|
|
68
|
+
|
|
69
|
+
def create_volume(self, name, size_gb, datacenter):
|
|
70
|
+
return self._req("POST", f"{REST}/networkvolumes", {"name": name, "size": size_gb, "dataCenterId": datacenter})
|
|
71
|
+
|
|
72
|
+
def delete_volume(self, volume_id):
|
|
73
|
+
return self._req("DELETE", f"{REST}/networkvolumes/{volume_id}")
|
|
74
|
+
|
|
75
|
+
_gql = gql # the name scripts written before 2026-10-26 use
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _price(g, cloud):
|
|
79
|
+
return g.get("communityPrice" if cloud == "COMMUNITY" else "securePrice") or None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def offers_from(gpu_types, clouds=("COMMUNITY", "SECURE"), countries=()):
|
|
83
|
+
"""One offer per GPU type and cloud that Runpod prices. With countries, stock is
|
|
84
|
+
the best stock in any of them and the offer is placed in the first stocked one;
|
|
85
|
+
without stock anywhere it still counts (Runpod's stock is a hint), ranked last."""
|
|
86
|
+
out = []
|
|
87
|
+
for cloud in clouds:
|
|
88
|
+
c = cloud.lower()
|
|
89
|
+
for g in gpu_types:
|
|
90
|
+
if not g.get("communityCloud" if cloud == "COMMUNITY" else "secureCloud"):
|
|
91
|
+
continue
|
|
92
|
+
p = _price(g, cloud)
|
|
93
|
+
if not p:
|
|
94
|
+
continue
|
|
95
|
+
if countries:
|
|
96
|
+
stocked = [(cc, (g.get(f"stock_{cc}_{c}") or {}).get("stockStatus")) for cc in countries]
|
|
97
|
+
stocked = [(cc, s) for cc, s in stocked if s]
|
|
98
|
+
country, stock = stocked[0] if stocked else (countries[0], None)
|
|
99
|
+
else:
|
|
100
|
+
country, stock = None, (g.get("lowestPrice") or {}).get("stockStatus")
|
|
101
|
+
out.append(Offer("runpod", g["id"], float(g.get("memoryInGb") or 0), p, where=f"{c} {country or ''}".strip(),
|
|
102
|
+
country=country,
|
|
103
|
+
stock=stock, udp=False, ref={"gpu_id": g["id"], "cloud": cloud}))
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _tcp_ports(ports):
|
|
108
|
+
"""Job ports ("8080/tcp", "8888/http") as Runpod wants them; 22 always."""
|
|
109
|
+
out = ["22/tcp"]
|
|
110
|
+
for p in ports:
|
|
111
|
+
num, _, proto = p.partition("/")
|
|
112
|
+
proto = (proto or "tcp").lower()
|
|
113
|
+
if proto == "udp":
|
|
114
|
+
raise Unavailable("Runpod pods cannot expose UDP")
|
|
115
|
+
if f"{num}/{proto}" not in out:
|
|
116
|
+
out.append(f"{num}/{proto}")
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Runpod(Provider):
|
|
121
|
+
name = "runpod"
|
|
122
|
+
key_name = "RUNPOD_API_KEY"
|
|
123
|
+
udp = False
|
|
124
|
+
|
|
125
|
+
def api(self):
|
|
126
|
+
return Api(self.key())
|
|
127
|
+
|
|
128
|
+
def offers(self, job):
|
|
129
|
+
clouds = [job.cloud] + (["SECURE"] if job.cloud == "COMMUNITY" else [])
|
|
130
|
+
regions = [r.upper() for r in job.regions]
|
|
131
|
+
if any(not re.fullmatch(r"[A-Z]{2}", r) for r in regions):
|
|
132
|
+
raise ProviderError(f"regions must be ISO country codes, got {regions}")
|
|
133
|
+
return offers_from(self.api().gpu_types(regions), clouds, regions)
|
|
134
|
+
|
|
135
|
+
def create(self, offer, job, pubkey, name):
|
|
136
|
+
api = self.api()
|
|
137
|
+
s = settings()
|
|
138
|
+
cloud = offer.ref["cloud"]
|
|
139
|
+
spec = {
|
|
140
|
+
"name": name, "imageName": job.image or s["image"], "gpuTypeIds": [offer.ref["gpu_id"]], "gpuCount": 1,
|
|
141
|
+
"cloudType": cloud, "computeType": "GPU", "containerDiskInGb": job.container_disk_gb, "volumeInGb": 0,
|
|
142
|
+
# direct ssh (tar streams) needs a public IP: community hosts must offer one; secure always does
|
|
143
|
+
"ports": _tcp_ports(job.ports), **({"supportPublicIp": True} if cloud == "COMMUNITY" else {}),
|
|
144
|
+
**_cuda(job.placement.min_cuda, s["cuda_versions"]),
|
|
145
|
+
"env": {"PUBLIC_KEY": pubkey, **job.env},
|
|
146
|
+
}
|
|
147
|
+
if job.volume: # the volume pins the data centre, which already fixes the country
|
|
148
|
+
vol = next((v for v in api.volumes() if v.get("name") == job.volume), None)
|
|
149
|
+
if not vol:
|
|
150
|
+
raise SystemExit(f"network volume {job.volume!r} not found")
|
|
151
|
+
spec.update(networkVolumeId=vol["id"], volumeMountPath="/workspace", dataCenterIds=[vol["dataCenterId"]])
|
|
152
|
+
elif job.regions:
|
|
153
|
+
spec["countryCodes"] = [r.upper() for r in job.regions]
|
|
154
|
+
try:
|
|
155
|
+
pod = api.create_pod(spec)
|
|
156
|
+
except ProviderError as e:
|
|
157
|
+
msg = str(e).lower()
|
|
158
|
+
if "no instances" in msg or "not available" in msg:
|
|
159
|
+
raise Unavailable(str(e)) from None
|
|
160
|
+
if "http 400" in msg and "gputypeids" in msg:
|
|
161
|
+
# GraphQL lists types (MIG slices, new SKUs) that POST /pods doesn't accept yet
|
|
162
|
+
raise Unavailable(f"Runpod won't create {offer.gpu} through its REST API") from None
|
|
163
|
+
raise
|
|
164
|
+
return {"id": pod["id"], "usd_h": pod.get("costPerHr") or offer.usd_h,
|
|
165
|
+
"gpu": (pod.get("machine") or {}).get("gpuTypeId") or offer.gpu}
|
|
166
|
+
|
|
167
|
+
def wait_ready(self, pod_id, job, timeout_s=900):
|
|
168
|
+
api = self.api()
|
|
169
|
+
t0 = time.time()
|
|
170
|
+
while time.time() - t0 < timeout_s:
|
|
171
|
+
p = api.get_pod(pod_id)
|
|
172
|
+
mapped = p.get("portMappings") or {}
|
|
173
|
+
ip = p.get("publicIp")
|
|
174
|
+
if ip and mapped.get("22"):
|
|
175
|
+
ports = {k: {"host": ip, "port": v} for k, v in mapped.items() if k != "22"}
|
|
176
|
+
return Remote(ip, mapped["22"]), {"machine": p.get("machineId"),
|
|
177
|
+
"gpu": (p.get("machine") or {}).get("gpuTypeId"), "ports": ports}
|
|
178
|
+
time.sleep(5)
|
|
179
|
+
raise BadHost(f"pod {pod_id} had no ssh endpoint after {timeout_s}s")
|
|
180
|
+
|
|
181
|
+
def terminate(self, pod_id):
|
|
182
|
+
self.api().terminate_pod(pod_id)
|
|
183
|
+
|
|
184
|
+
def list_ours(self):
|
|
185
|
+
out = []
|
|
186
|
+
for p in self.api().list_pods():
|
|
187
|
+
if (p.get("name") or "").startswith("gipper-"):
|
|
188
|
+
started = p.get("lastStartedAt")
|
|
189
|
+
try:
|
|
190
|
+
started = datetime.fromisoformat(started.replace("Z", "+00:00")).timestamp() if started else None
|
|
191
|
+
except ValueError:
|
|
192
|
+
started = None
|
|
193
|
+
out.append({"id": p["id"], "name": p["name"], "started": started,
|
|
194
|
+
"status": p.get("desiredStatus"), "usd_h": p.get("costPerHr")})
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
def billed(self, since):
|
|
198
|
+
"""{pod id: (usd, detail)} Runpod billed since `since` (epoch s), per pod per day."""
|
|
199
|
+
start = datetime.fromtimestamp(since, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
200
|
+
rows = self.api()._req("GET", f"{REST}/billing/pods?bucketSize=day&grouping=podId&startTime={start}")
|
|
201
|
+
out = {}
|
|
202
|
+
for r in rows if isinstance(rows, list) else []:
|
|
203
|
+
usd, detail = out.get(r["podId"], (0.0, {}))
|
|
204
|
+
detail["billed_s"] = round(detail.get("billed_s", 0) + (r.get("timeBilledMs") or 0) / 1000)
|
|
205
|
+
detail["disk_gb"] = r.get("diskSpaceBilledGB")
|
|
206
|
+
out[r["podId"]] = (usd + (r.get("amount") or 0), detail)
|
|
207
|
+
return out
|
|
208
|
+
|
|
209
|
+
def account(self):
|
|
210
|
+
a = self.api().account()
|
|
211
|
+
return f"balance ${a['clientBalance']:.2f}; spending ${a['currentSpendPerHr']:.2f}/h now; spend limit {a['spendLimit']}"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
PROVIDER = Runpod()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _cuda(min_cuda, configured):
|
|
218
|
+
"""allowedCudaVersions: the configured list, or every version Runpod knows at or
|
|
219
|
+
above the job's min_cuda; nothing (any host) if neither is set."""
|
|
220
|
+
versions = list(configured or CUDA_VERSIONS)
|
|
221
|
+
if min_cuda:
|
|
222
|
+
versions = [v for v in versions if tuple(map(int, v.split("."))) >= tuple(map(int, f"{min_cuda}".split(".")[:2]))]
|
|
223
|
+
if (configured or min_cuda) and not versions: # an empty list would mean "any" to Runpod
|
|
224
|
+
raise Unavailable(f"no Runpod CUDA version satisfies min_cuda {min_cuda}")
|
|
225
|
+
return {"allowedCudaVersions": versions} if (configured or min_cuda) else {}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Shadeform: one account and one bill across 30+ clouds (Lambda, Hyperstack,
|
|
2
|
+
Massed Compute, Crusoe, ...). Instances are whole VMs with their own public IP,
|
|
3
|
+
reached by ssh as `shadeform` (not root); drivers come with the "shade_os" images.
|
|
4
|
+
Ports, UDP included, are opened with UFW rules at boot. The underlying cloud may
|
|
5
|
+
have its own firewall too, so test a UDP job's ports before relying on them.
|
|
6
|
+
|
|
7
|
+
Each instance is also created with Shadeform's own auto_delete (the job's deadline
|
|
8
|
+
and a spend ceiling), a second guard behind gipper's teardown and reaper.
|
|
9
|
+
|
|
10
|
+
API: https://docs.shadeform.ai/api-reference (the catalogue is public).
|
|
11
|
+
"""
|
|
12
|
+
import time
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
|
|
15
|
+
from gipper.http import ProviderError, request
|
|
16
|
+
from gipper.market import Offer
|
|
17
|
+
from gipper.providers import BadHost, Provider, Unavailable
|
|
18
|
+
from gipper.ssh import Remote
|
|
19
|
+
|
|
20
|
+
BASE = "https://api.shadeform.ai/v1"
|
|
21
|
+
ERROR = ("error", "failed", "deleted", "deleting")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _country(display_name):
|
|
25
|
+
"""Availability display names lead with the country: "US, Des Moines" -> "US"."""
|
|
26
|
+
head = (display_name or "").split(",", 1)[0].strip().upper()
|
|
27
|
+
return head if len(head) == 2 else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def offers_from(types):
|
|
31
|
+
"""One offer per GPU, cloud, region and price: a cloud often lists the same card
|
|
32
|
+
under several instance types ("A6000", "A6000_plus", "A6000_base")."""
|
|
33
|
+
out, seen = [], set()
|
|
34
|
+
for t in types or []:
|
|
35
|
+
conf = t.get("configuration") or {}
|
|
36
|
+
if (t.get("num_gpus") or conf.get("num_gpus")) != 1:
|
|
37
|
+
continue
|
|
38
|
+
for a in t.get("availability") or []:
|
|
39
|
+
if not a.get("available") or a.get("rental_type", "on_demand") != "on_demand":
|
|
40
|
+
continue
|
|
41
|
+
key = (t["cloud"], a["region"], t.get("gpu_type"), t["hourly_price"])
|
|
42
|
+
if key in seen:
|
|
43
|
+
continue
|
|
44
|
+
seen.add(key)
|
|
45
|
+
out.append(Offer("shadeform", t.get("gpu_type") or t["shade_instance_type"],
|
|
46
|
+
float(conf.get("vram_per_gpu_in_gb") or 0), t["hourly_price"] / 100,
|
|
47
|
+
where=f"{t['cloud']} {a['region']}", country=_country(a.get("display_name")),
|
|
48
|
+
stock="yes", udp=True,
|
|
49
|
+
ref={"cloud": t["cloud"], "region": a["region"], "type": t["shade_instance_type"],
|
|
50
|
+
"os": next((o for o in conf.get("os_options") or [] if "cuda" in o), None),
|
|
51
|
+
"boot_s": (t.get("boot_time") or {}).get("max_boot_in_sec")}))
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _ufw(ports):
|
|
56
|
+
rules = [{"port": "22", "proto": "tcp"}]
|
|
57
|
+
for p in ports:
|
|
58
|
+
num, _, proto = p.partition("/")
|
|
59
|
+
rules.append({"port": num.replace("-", ":"), "proto": (proto or "tcp").lower()})
|
|
60
|
+
return rules
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Shadeform(Provider):
|
|
64
|
+
name = "shadeform"
|
|
65
|
+
key_name = "SHADEFORM_API_KEY"
|
|
66
|
+
udp = True
|
|
67
|
+
|
|
68
|
+
def _req(self, method, path, body=None, params=None, auth=True):
|
|
69
|
+
headers = {"X-API-KEY": self.key()} if auth else {}
|
|
70
|
+
return request(method, f"{BASE}{path}", body, headers=headers, params=params)
|
|
71
|
+
|
|
72
|
+
def offers(self, job):
|
|
73
|
+
d = self._req("GET", "/instances/types", params={"num_gpus": 1, "available": "true", "sort": "price"}, auth=False)
|
|
74
|
+
return offers_from(d.get("instance_types"))
|
|
75
|
+
|
|
76
|
+
def _key_id(self, pubkey):
|
|
77
|
+
body = pubkey.split()[1]
|
|
78
|
+
for k in self._req("GET", "/sshkeys").get("ssh_keys") or []:
|
|
79
|
+
if body in (k.get("public_key") or ""):
|
|
80
|
+
return k["id"]
|
|
81
|
+
return self._req("POST", "/sshkeys/add", {"name": "gipper", "public_key": pubkey})["id"]
|
|
82
|
+
|
|
83
|
+
def create(self, offer, job, pubkey, name):
|
|
84
|
+
deadline = datetime.now(timezone.utc) + timedelta(hours=job.max_hours, minutes=30)
|
|
85
|
+
spec = {"cloud": offer.ref["cloud"], "region": offer.ref["region"], "shade_instance_type": offer.ref["type"],
|
|
86
|
+
"shade_cloud": True, "name": name[:50], "ssh_key_id": self._key_id(pubkey), "tags": ["gipper"],
|
|
87
|
+
"auto_delete": {"date_threshold": deadline.isoformat(timespec="seconds").replace("+00:00", "Z"),
|
|
88
|
+
"spend_threshold": f"{max(job.max_usd * 1.5, 1):.2f}"}}
|
|
89
|
+
if offer.ref.get("os"):
|
|
90
|
+
spec["os"] = offer.ref["os"]
|
|
91
|
+
if job.ports:
|
|
92
|
+
spec["networking"] = {"ufw_rules": _ufw(job.ports)}
|
|
93
|
+
try:
|
|
94
|
+
d = self._req("POST", "/instances/create", spec)
|
|
95
|
+
except ProviderError as e:
|
|
96
|
+
if any(w in str(e).lower() for w in ("capacity", "not available", "unavailable", "out of stock")):
|
|
97
|
+
raise Unavailable(str(e)) from None
|
|
98
|
+
raise
|
|
99
|
+
return {"id": d["id"], "usd_h": offer.usd_h, "gpu": offer.gpu}
|
|
100
|
+
|
|
101
|
+
def wait_ready(self, iid, job, timeout_s=1200): # VMs boot slower than containers (5-8 min listed)
|
|
102
|
+
t0 = time.time()
|
|
103
|
+
while time.time() - t0 < timeout_s:
|
|
104
|
+
i = self._req("GET", f"/instances/{iid}/info")
|
|
105
|
+
status = (i.get("status") or "").lower()
|
|
106
|
+
if status in ERROR or "error" in status:
|
|
107
|
+
raise BadHost(f"instance {iid} is {status}: {i.get('status_details') or ''}".strip())
|
|
108
|
+
if status == "active" and i.get("ip"):
|
|
109
|
+
# a whole VM: the job's ports are its own, unless the cloud forwards them
|
|
110
|
+
fwd = {f["internal_port"]: f["external_port"] for f in i.get("port_forwards") or []}
|
|
111
|
+
ports = {}
|
|
112
|
+
for p in job.ports:
|
|
113
|
+
num, _, proto = p.partition("/")
|
|
114
|
+
if num.isdigit():
|
|
115
|
+
ports[f"{num}/{(proto or 'tcp').lower()}"] = {"host": i["ip"], "port": fwd.get(int(num), int(num))}
|
|
116
|
+
return Remote(i["ip"], i.get("ssh_port") or fwd.get(22, 22), user=i.get("ssh_user") or "shadeform"), {
|
|
117
|
+
"machine": i.get("cloud_assigned_id"), "gpu": i.get("shade_instance_type"), "ports": ports,
|
|
118
|
+
"where": f"{i.get('cloud')} {i.get('region')}"}
|
|
119
|
+
time.sleep(10)
|
|
120
|
+
raise BadHost(f"instance {iid} not active after {timeout_s}s")
|
|
121
|
+
|
|
122
|
+
def terminate(self, iid):
|
|
123
|
+
self._req("POST", f"/instances/{iid}/delete")
|
|
124
|
+
|
|
125
|
+
def list_ours(self):
|
|
126
|
+
out = []
|
|
127
|
+
for i in self._req("GET", "/instances").get("instances") or []:
|
|
128
|
+
if (i.get("name") or "").startswith("gipper-") and i.get("status") not in ("deleting", "deleted"):
|
|
129
|
+
started = i.get("created_at")
|
|
130
|
+
try:
|
|
131
|
+
started = datetime.fromisoformat(started.replace("Z", "+00:00")).timestamp() if started else None
|
|
132
|
+
except ValueError:
|
|
133
|
+
started = None
|
|
134
|
+
out.append({"id": i["id"], "name": i["name"], "started": started, "status": i.get("status"),
|
|
135
|
+
"usd_h": (i.get("hourly_price") or 0) / 100})
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
def account(self):
|
|
139
|
+
n = len(self.list_ours())
|
|
140
|
+
return f"key works; {n} gipper instance(s) running (the API reports no balance)"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
PROVIDER = Shadeform()
|
gipper/providers/vast.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Vast.ai: a marketplace of individual machines (many consumer GPUs). Each offer
|
|
2
|
+
is one machine ("ask"); renting it makes a Docker container with direct ssh as
|
|
3
|
+
root. Ports, UDP included, are published with docker `-p` options and land on a
|
|
4
|
+
random external port of the host's (usually shared) public IP; gipper hands the
|
|
5
|
+
mapping to the job as GIPPER_PORT_<n>_<PROTO>.
|
|
6
|
+
|
|
7
|
+
API: https://docs.vast.ai/api-reference (search is public; renting needs the key).
|
|
8
|
+
"""
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from gipper.config import settings
|
|
12
|
+
from gipper.http import ProviderError, request
|
|
13
|
+
from gipper.market import Offer
|
|
14
|
+
from gipper.providers import BadHost, Provider, Unavailable
|
|
15
|
+
from gipper.ssh import Remote
|
|
16
|
+
|
|
17
|
+
BASE = "https://console.vast.ai/api"
|
|
18
|
+
MIN_RELIABILITY = 0.97 # Vast's own uptime score for the machine
|
|
19
|
+
MIN_INET_DOWN = 100 # Mb/s: inputs and model weights have to get there
|
|
20
|
+
PER_GPU = 8 # machines per GPU model: enough variety in speed and track record
|
|
21
|
+
DEAD = ("exited", "unknown", "offline") # never reaches running from these (Vast's docs)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _port_opts(ports):
|
|
25
|
+
"""["8082/udp", "8080"] -> {"-p 8082:8082/udp": "1", "-p 8080:8080": "1"}"""
|
|
26
|
+
out = {}
|
|
27
|
+
for p in ports:
|
|
28
|
+
num, _, proto = p.partition("/")
|
|
29
|
+
proto = (proto or "tcp").lower()
|
|
30
|
+
out[f"-p {num}:{num}" + ("/udp" if proto == "udp" else "")] = "1"
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def offers_from(rows, per_gpu=PER_GPU):
|
|
35
|
+
"""Offers from Vast search rows. Memory is decimal GB from Vast's MiB (an A40's
|
|
36
|
+
46068 MiB is 48.3 GB, a 4090's 24564 MiB is 25.8 GB), so nominal sizes qualify.
|
|
37
|
+
Speeds are Mb/s and fees $/GB, as Vast advertises them."""
|
|
38
|
+
out, per = [], {}
|
|
39
|
+
for o in rows:
|
|
40
|
+
name = o.get("gpu_name") or "?"
|
|
41
|
+
if per.get(name, 0) >= per_gpu:
|
|
42
|
+
continue
|
|
43
|
+
per[name] = per.get(name, 0) + 1
|
|
44
|
+
geo = o.get("geolocation") or ""
|
|
45
|
+
cap = o.get("compute_cap")
|
|
46
|
+
out.append(Offer("vast", name, (o.get("gpu_ram") or 0) * 1024**2 / 1e9, float(o["dph_total"]), where=geo,
|
|
47
|
+
country=geo.rsplit(",", 1)[-1].strip().upper() or None, stock="yes", udp=True,
|
|
48
|
+
ref={"ask": o["id"], "machine": str(o["machine_id"]) if o.get("machine_id") else None},
|
|
49
|
+
metrics={"host_download_mbps": o.get("inet_down"), "host_upload_mbps": o.get("inet_up"),
|
|
50
|
+
"host_download_usd_per_gb": o.get("inet_down_cost"),
|
|
51
|
+
"host_upload_usd_per_gb": o.get("inet_up_cost"),
|
|
52
|
+
"cuda_max_good": o.get("cuda_max_good"),
|
|
53
|
+
"compute_capability": cap / 100 if cap and cap >= 100 else cap,
|
|
54
|
+
"dlperf": o.get("dlperf"),
|
|
55
|
+
"public_ip": o.get("public_ipaddr"), # pingable before renting
|
|
56
|
+
"reliability": o.get("reliability")}))
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Vast(Provider):
|
|
61
|
+
name = "vast"
|
|
62
|
+
key_name = "VAST_API_KEY"
|
|
63
|
+
udp = True
|
|
64
|
+
|
|
65
|
+
def _req(self, method, path, body=None, params=None, auth=True):
|
|
66
|
+
headers = {"Authorization": f"Bearer {self.key()}"} if auth else {}
|
|
67
|
+
return request(method, f"{BASE}{path}", body, headers=headers, params=params)
|
|
68
|
+
|
|
69
|
+
def offers(self, job):
|
|
70
|
+
p = job.placement
|
|
71
|
+
q = {"verified": {"eq": True}, "external": {"eq": False}, "rentable": {"eq": True}, "rented": {"eq": False},
|
|
72
|
+
"num_gpus": {"eq": 1}, "gpu_ram": {"gte": job.min_mem_gb * 1e9 / 1024**2}, # MiB in the API
|
|
73
|
+
"reliability": {"gte": MIN_RELIABILITY},
|
|
74
|
+
"inet_down": {"gte": max(MIN_INET_DOWN, p.min_host_download_mbps or 0)},
|
|
75
|
+
"direct_port_count": {"gte": 1 + len(job.ports)}, # ssh plus the job's own
|
|
76
|
+
"type": "ondemand", "order": [["dph_total", "asc"]], "limit": 200,
|
|
77
|
+
"allocated_storage": job.container_disk_gb} # so dph_total includes the disk
|
|
78
|
+
for key, floor in (("inet_up", p.min_host_upload_mbps), ("cuda_max_good", p.min_cuda), ("dlperf", p.min_dlperf),
|
|
79
|
+
("compute_cap", p.min_compute_capability * 100 if p.min_compute_capability else None)):
|
|
80
|
+
if floor:
|
|
81
|
+
q[key] = {"gte": floor}
|
|
82
|
+
if job.regions:
|
|
83
|
+
q["geolocation"] = {"in": [r.upper() for r in job.regions]}
|
|
84
|
+
return offers_from(self._req("POST", "/v0/bundles/", q, auth=self.has_key()).get("offers", []))
|
|
85
|
+
|
|
86
|
+
def _ensure_key(self, pubkey):
|
|
87
|
+
"""Vast adds account keys to new instances; register gipper's once."""
|
|
88
|
+
d = self._req("GET", "/v0/ssh/")
|
|
89
|
+
keys = d if isinstance(d, list) else d.get("ssh_keys", d.get("keys", []))
|
|
90
|
+
body = pubkey.split()[1]
|
|
91
|
+
if not any(body in (k.get("public_key") or k.get("ssh_key") or "") for k in keys if isinstance(k, dict)):
|
|
92
|
+
self._req("POST", "/v0/ssh/", {"ssh_key": pubkey})
|
|
93
|
+
|
|
94
|
+
def create(self, offer, job, pubkey, name):
|
|
95
|
+
self._ensure_key(pubkey)
|
|
96
|
+
spec = {"client_id": "me", "image": job.image or settings()["vast_image"], "disk": job.container_disk_gb,
|
|
97
|
+
"runtype": "ssh_direct", "label": name, "env": _port_opts(job.ports), "cancel_unavail": True}
|
|
98
|
+
try:
|
|
99
|
+
d = self._req("PUT", f"/v0/asks/{offer.ref['ask']}/", spec)
|
|
100
|
+
except ProviderError as e:
|
|
101
|
+
if any(w in str(e).lower() for w in ("no_such_ask", "not available", "unavailable", "already rented", "http 404")):
|
|
102
|
+
raise Unavailable(str(e)) from None
|
|
103
|
+
raise
|
|
104
|
+
if not d.get("success") or not d.get("new_contract"):
|
|
105
|
+
raise Unavailable(f"vast would not rent ask {offer.ref['ask']}: {d.get('msg') or d.get('error') or d}")
|
|
106
|
+
return {"id": str(d["new_contract"]), "usd_h": offer.usd_h, "gpu": offer.gpu}
|
|
107
|
+
|
|
108
|
+
def get(self, iid):
|
|
109
|
+
d = self._req("GET", f"/v0/instances/{iid}/")
|
|
110
|
+
inst = d.get("instances", d)
|
|
111
|
+
return inst[0] if isinstance(inst, list) else (inst or {})
|
|
112
|
+
|
|
113
|
+
def wait_ready(self, iid, job, timeout_s=900):
|
|
114
|
+
t0 = time.time()
|
|
115
|
+
while time.time() - t0 < timeout_s:
|
|
116
|
+
i = self.get(iid)
|
|
117
|
+
status = i.get("actual_status")
|
|
118
|
+
if status in DEAD:
|
|
119
|
+
raise BadHost(f"instance {iid} is {status}: {i.get('status_msg') or ''}".strip())
|
|
120
|
+
mapped = i.get("ports") or {}
|
|
121
|
+
ip = i.get("public_ipaddr")
|
|
122
|
+
ssh = mapped.get("22/tcp") if isinstance(mapped, dict) else None
|
|
123
|
+
if status == "running" and ip and ssh:
|
|
124
|
+
ports = {k: {"host": ip, "port": int(v[0]["HostPort"])}
|
|
125
|
+
for k, v in mapped.items() if k != "22/tcp" and v}
|
|
126
|
+
return Remote(ip.strip(), int(ssh[0]["HostPort"])), {
|
|
127
|
+
"machine": i.get("machine_id"), "gpu": i.get("gpu_name"), "ports": ports,
|
|
128
|
+
"where": i.get("geolocation")}
|
|
129
|
+
time.sleep(5)
|
|
130
|
+
raise BadHost(f"instance {iid} had no ssh endpoint after {timeout_s}s")
|
|
131
|
+
|
|
132
|
+
def terminate(self, iid):
|
|
133
|
+
self._req("DELETE", f"/v0/instances/{iid}/", {})
|
|
134
|
+
|
|
135
|
+
def list_ours(self):
|
|
136
|
+
out, params = [], {"select_filters": {}, "order_by": [{"col": "id", "dir": "asc"}], "limit": 25}
|
|
137
|
+
while True:
|
|
138
|
+
d = self._req("GET", "/v1/instances/", params=params)
|
|
139
|
+
for i in d.get("instances") or []:
|
|
140
|
+
if (i.get("label") or "").startswith("gipper-"):
|
|
141
|
+
out.append({"id": str(i["id"]), "name": i["label"], "started": i.get("start_date"),
|
|
142
|
+
"status": i.get("actual_status"), "usd_h": i.get("dph_total")})
|
|
143
|
+
if not d.get("next_token"):
|
|
144
|
+
return out
|
|
145
|
+
params["after_token"] = d["next_token"]
|
|
146
|
+
|
|
147
|
+
def billed(self, since):
|
|
148
|
+
"""{instance id: (usd, detail)} Vast charged since `since` (epoch s), from its
|
|
149
|
+
itemised charges: GPU while running, disk from creation, bandwidth by the byte."""
|
|
150
|
+
out, params = {}, {"select_filters": {"day": {"gte": int(since), "lte": int(time.time())}}, "limit": 20}
|
|
151
|
+
while True:
|
|
152
|
+
d = self._req("GET", "/v0/charges/", params=params)
|
|
153
|
+
for r in d.get("results") or []:
|
|
154
|
+
if r.get("type") != "instance" or not str(r.get("source", "")).startswith("instance-"):
|
|
155
|
+
continue
|
|
156
|
+
iid = r["source"].split("-", 1)[1]
|
|
157
|
+
usd, detail = out.get(iid, (0.0, {}))
|
|
158
|
+
for item in r.get("items") or []:
|
|
159
|
+
detail[item["type"]] = round(detail.get(item["type"], 0) + item.get("amount", 0), 6)
|
|
160
|
+
out[iid] = (usd + r.get("amount", 0), detail)
|
|
161
|
+
if not d.get("next_token"):
|
|
162
|
+
return out
|
|
163
|
+
params["after_token"] = d["next_token"]
|
|
164
|
+
|
|
165
|
+
def account(self):
|
|
166
|
+
u = self._req("GET", "/v0/users/current/")
|
|
167
|
+
return f"credit ${float(u.get('credit') or 0):.2f}; balance ${float(u.get('balance') or 0):.2f}"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
PROVIDER = Vast()
|
gipper/reap.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Find and end rented GPUs gipper started and forgot, on every provider with a key.
|
|
2
|
+
|
|
3
|
+
A machine is reaped when its name starts with `gipper-` and either the ledger has
|
|
4
|
+
no live run for it (created but never terminated by a running client) past a grace
|
|
5
|
+
period, or it is past the deadline recorded at creation. Machines not named
|
|
6
|
+
`gipper-` are never touched.
|
|
7
|
+
"""
|
|
8
|
+
import time
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from gipper import ledger
|
|
12
|
+
from gipper.config import settings
|
|
13
|
+
from gipper.providers import load
|
|
14
|
+
|
|
15
|
+
GRACE_S = 20 * 60 # a client may still be uploading or waiting for ssh
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def state():
|
|
19
|
+
"""(provider, id) -> {job, created, deadline, terminated}; receipts from before
|
|
20
|
+
there was more than one provider are Runpod's."""
|
|
21
|
+
known = {}
|
|
22
|
+
for r in ledger.read(kinds={"pod.create", "pod.terminate", "pod.closure_verified", "pod.termination_pending", "reap"}):
|
|
23
|
+
pid = r["payload"].get("pod")
|
|
24
|
+
if not pid:
|
|
25
|
+
continue
|
|
26
|
+
k = (r["payload"].get("provider") or "runpod", str(pid))
|
|
27
|
+
p = known.setdefault(k, {"job": r["job"], "terminated": False})
|
|
28
|
+
if r["kind"] == "pod.create":
|
|
29
|
+
p["created"] = r["ts"]
|
|
30
|
+
p["deadline"] = r["payload"].get("deadline")
|
|
31
|
+
p["usd_h"] = r["payload"].get("usd_per_h")
|
|
32
|
+
try:
|
|
33
|
+
p["started"] = datetime.fromisoformat(r["ts"]).timestamp()
|
|
34
|
+
except (ValueError, TypeError):
|
|
35
|
+
p["started"] = None
|
|
36
|
+
elif r["kind"] == "pod.termination_pending":
|
|
37
|
+
p["pending"] = True
|
|
38
|
+
if "delete_accepted" in r["payload"]:
|
|
39
|
+
p["delete_accepted"] = r["payload"]["delete_accepted"]
|
|
40
|
+
p["terminated"] = False
|
|
41
|
+
else:
|
|
42
|
+
p["terminated"] = True
|
|
43
|
+
p["pending"] = False
|
|
44
|
+
return known
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def verdict(rec, machine, now):
|
|
48
|
+
if rec is None:
|
|
49
|
+
started = machine.get("started")
|
|
50
|
+
return None if started and now - started < GRACE_S else "not in the ledger"
|
|
51
|
+
if rec.get("pending"):
|
|
52
|
+
return "termination pending; closure unverified"
|
|
53
|
+
if rec["terminated"]:
|
|
54
|
+
return "ledger says terminated but it is still listed"
|
|
55
|
+
if rec.get("deadline") and now > rec["deadline"]:
|
|
56
|
+
return "past its deadline"
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def reap(dry_run=False, providers=None):
|
|
61
|
+
"""[(provider, id, name, why)] acted on (or that would be, with dry_run)."""
|
|
62
|
+
known = state()
|
|
63
|
+
acted = []
|
|
64
|
+
now = time.time()
|
|
65
|
+
for p in providers or [load(n) for n in settings()["providers"]]:
|
|
66
|
+
if not p.has_key():
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
machines = p.list_ours()
|
|
70
|
+
except Exception as e: # one provider being down must not stop the others' sweep
|
|
71
|
+
print(f"{p.name}: could not list machines: {e}")
|
|
72
|
+
continue
|
|
73
|
+
# An accepted but asynchronous DELETE can disappear between sweeps.
|
|
74
|
+
# Keep its ledger claim until a new positive provider read confirms it.
|
|
75
|
+
listed = {str(m["id"]) for m in machines}
|
|
76
|
+
machines = list(machines) + [{"id": iid, "name": "gipper-pending-recovery"}
|
|
77
|
+
for (provider, iid), rec in known.items()
|
|
78
|
+
if provider == p.name and rec.get("pending") and rec.get("delete_accepted") and iid not in listed]
|
|
79
|
+
for m in machines:
|
|
80
|
+
why = verdict(known.get((p.name, str(m["id"]))), m, now)
|
|
81
|
+
if not why:
|
|
82
|
+
continue
|
|
83
|
+
acted.append((p.name, m["id"], m["name"], why))
|
|
84
|
+
if not dry_run:
|
|
85
|
+
from gipper.lifecycle import terminate
|
|
86
|
+
rec = known.get((p.name, str(m["id"]))) or {}
|
|
87
|
+
try:
|
|
88
|
+
closed = terminate(p, m["id"], job_id=rec.get("job"), started=rec.get("started"),
|
|
89
|
+
usd_h=rec.get("usd_h"), delete_accepted=bool(rec.get("delete_accepted")))
|
|
90
|
+
except Exception as error:
|
|
91
|
+
print(f"{p.name}: cleanup pending for {m['id']}: {error}")
|
|
92
|
+
continue
|
|
93
|
+
if closed:
|
|
94
|
+
ledger.write("reap", {"provider": p.name, "pod": m["id"], "name": m["name"], "why": why}, job=rec.get("job"))
|
|
95
|
+
return acted
|