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.
@@ -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()
@@ -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