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
gipper/run.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""`gipper pod run JOB`: rent a GPU from whichever market serves the job best, run
|
|
2
|
+
the job, bring the results home, and always give the GPU back.
|
|
3
|
+
|
|
4
|
+
Lifecycle (every step receipted):
|
|
5
|
+
shop (every provider with a key; regions, ports, memory, [placement] floors)
|
|
6
|
+
-> rank by whole-run score (gipper.costs, using gipper.history) -> caps
|
|
7
|
+
-> rent the best offer (next one if it's gone) -> wait for ssh -> preflight the GPU
|
|
8
|
+
and CUDA, optionally probe the network (replace a bad host, up to 3)
|
|
9
|
+
-> upload inputs (timed: memory learns real transfer speeds)
|
|
10
|
+
-> start the job detached (nohup, under `timeout max_hours`)
|
|
11
|
+
-> stream its log, sync checkpoints, enforce max_usd / max_hours
|
|
12
|
+
-> download outputs -> terminate (in `finally`, whatever happened) -> spend
|
|
13
|
+
|
|
14
|
+
The job runs detached so a dropped ssh connection doesn't kill it; the in-machine
|
|
15
|
+
`timeout` bounds it even if this client dies, and the reaper (`gipper pod reap`)
|
|
16
|
+
ends any gipper machine the ledger says should be gone, on every provider.
|
|
17
|
+
"""
|
|
18
|
+
import shlex
|
|
19
|
+
import signal
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from gipper import ledger, market
|
|
25
|
+
from gipper.config import DATA, settings
|
|
26
|
+
from gipper.placement import input_gb
|
|
27
|
+
from gipper.http import ProviderError
|
|
28
|
+
from gipper.providers import BadHost, Unavailable, load
|
|
29
|
+
from gipper.ssh import ensure_key
|
|
30
|
+
|
|
31
|
+
BOOTSTRAP = Path(__file__).with_name("bootstrap.sh")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CleanupPending(RuntimeError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Unsuitable(BadHost):
|
|
39
|
+
"""The host works but doesn't suit this job (compute capability, a runtime the
|
|
40
|
+
job pins). Rejected and replaced like any bad host, but not held against it in
|
|
41
|
+
memory. Client preflight wrappers should raise this for job-specific checks."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Capped(Exception):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _now():
|
|
49
|
+
return time.time()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def plan(job, *, provider=None, gpu=None, throughput=None):
|
|
53
|
+
"""(offers worth trying best first, hourly ceiling, notes). Offers above the job's
|
|
54
|
+
own max_usd_per_h, or whose expected cost exceeds its budget, are left out."""
|
|
55
|
+
offers, notes = market.shop(job, names=[provider] if provider else None, throughput=throughput)
|
|
56
|
+
if gpu:
|
|
57
|
+
offers = [o for o in offers if market.matches(gpu, o.gpu)]
|
|
58
|
+
if job.max_usd_per_h:
|
|
59
|
+
offers = [o for o in offers if o.usd_h <= job.max_usd_per_h]
|
|
60
|
+
over = [o for o in offers if o.estimate.get("usd", 0) > job.max_usd]
|
|
61
|
+
if over and len(over) < len(offers):
|
|
62
|
+
notes.append(f"{len(over)} offer(s) left out: expected cost above the job's ${job.max_usd} budget")
|
|
63
|
+
offers = [o for o in offers if o not in over]
|
|
64
|
+
if not offers:
|
|
65
|
+
where = f" in {', '.join(job.regions)}" if job.regions else ""
|
|
66
|
+
udp = " with UDP" if market.wants_udp(job.ports) else ""
|
|
67
|
+
cap = f" at <= ${job.max_usd_per_h}/h" if job.max_usd_per_h else ""
|
|
68
|
+
raise SystemExit(f"no GPU{' ' + gpu if gpu else ''} with >= {job.min_mem_gb} GB{where}{udp}{cap} is on offer right now"
|
|
69
|
+
+ "".join(f"\n {n}" for n in notes))
|
|
70
|
+
return offers, job.max_usd_per_h or max(o.usd_h for o in offers), notes
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def estimate(job, gpu_price=None, throughput=None, setup_s=420, *, offer=None):
|
|
74
|
+
"""The whole-run estimate for an offer (gipper.costs); without one, 0.1's rough
|
|
75
|
+
guess from the hourly price."""
|
|
76
|
+
if offer is not None:
|
|
77
|
+
if not offer.estimate or throughput:
|
|
78
|
+
from gipper.costs import estimate as whole_run
|
|
79
|
+
from gipper.history import History
|
|
80
|
+
offer.estimate = whole_run(job, offer, History.load(), throughput=throughput)
|
|
81
|
+
return {**offer.estimate, "basis_summary": _basis(offer.estimate)}
|
|
82
|
+
wall = (setup_s + job.units_count / throughput) if throughput and job.units_count else min(job.max_hours * 3600, setup_s + 1800)
|
|
83
|
+
return {"wall_s": round(wall), "usd": round(wall / 3600 * gpu_price, 3), "basis": "guess", "basis_summary": "guess"}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _basis(e):
|
|
87
|
+
return ", ".join(f"{k} {v}" for k, v in e.get("basis", {}).items() if v not in ("default", "rate")) or "defaults"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _rent(queue, job, pubkey, name):
|
|
91
|
+
"""Take offers in order until one is rented: (provider, offer, created)."""
|
|
92
|
+
while queue:
|
|
93
|
+
o = queue.pop(0)
|
|
94
|
+
p = load(o.provider)
|
|
95
|
+
try:
|
|
96
|
+
return p, o, p.create(o, job, pubkey, name)
|
|
97
|
+
except Unavailable as e:
|
|
98
|
+
print(f"{o.label} ({o.where}) not available: {str(e)[:120]}")
|
|
99
|
+
raise Unavailable("nothing at or under the price ceiling could be rented")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def preflight(remote):
|
|
103
|
+
"""Reject a host whose GPU is already busy or broken before any upload: the
|
|
104
|
+
community hosts seen on 2026-10-20 included one at 100% use and 1 GB held at
|
|
105
|
+
start, and two whose first CUDA call failed."""
|
|
106
|
+
r = remote.run("nvidia-smi --query-gpu=utilization.gpu,memory.used,compute_mode --format=csv,noheader,nounits; "
|
|
107
|
+
"nvidia-smi --query-compute-apps=pid --format=csv,noheader | wc -l", check=False, timeout=60)
|
|
108
|
+
if r.returncode:
|
|
109
|
+
raise BadHost(f"nvidia-smi failed: {r.stderr.strip()[-200:]}")
|
|
110
|
+
lines = [l for l in r.stdout.strip().splitlines() if l.strip()]
|
|
111
|
+
util, mem, mode = [x.strip() for x in lines[0].split(",")]
|
|
112
|
+
procs = int(lines[-1].strip() or 0)
|
|
113
|
+
if int(util) > 30 or int(mem) > 800 or procs > 0:
|
|
114
|
+
raise BadHost(f"GPU busy at start: {util}% used, {mem} MiB held, {procs} processes, mode {mode}")
|
|
115
|
+
return {"util": int(util), "mem_mib": int(mem), "mode": mode}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def requirements(remote, job):
|
|
119
|
+
"""The job's [placement] floors, checked on the machine itself (a provider may
|
|
120
|
+
not advertise them, or advertise them wrong)."""
|
|
121
|
+
want = job.placement.min_compute_capability
|
|
122
|
+
if not want:
|
|
123
|
+
return {}
|
|
124
|
+
r = remote.run("nvidia-smi --query-gpu=compute_cap --format=csv,noheader", check=False, timeout=60)
|
|
125
|
+
try:
|
|
126
|
+
have = float(r.stdout.strip().splitlines()[0])
|
|
127
|
+
except (ValueError, IndexError):
|
|
128
|
+
raise BadHost(f"could not read the GPU's compute capability: {r.stderr.strip()[-200:]}") from None
|
|
129
|
+
if have < want:
|
|
130
|
+
raise Unsuitable(f"GPU compute capability {have} is below the job's {want}")
|
|
131
|
+
return {"compute_capability": have}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _bytes(path):
|
|
135
|
+
p = Path(path)
|
|
136
|
+
if p.is_file():
|
|
137
|
+
return p.stat().st_size
|
|
138
|
+
return sum(f.stat().st_size for f in p.rglob("*") if f.is_file() and not f.is_symlink()) if p.exists() else 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def run(job, *, dry_run=False, log_dir=None, gpu_override=None, provider=None, throughput=None):
|
|
142
|
+
s = settings()
|
|
143
|
+
run_id = ledger.new_id("job")
|
|
144
|
+
if job.sensitivity == "owned-only":
|
|
145
|
+
raise SystemExit(f"{job.name}: sensitivity = owned-only; not running on a rented GPU")
|
|
146
|
+
offers, ceiling, notes = plan(job, provider=provider, gpu=gpu_override, throughput=throughput)
|
|
147
|
+
for n in notes:
|
|
148
|
+
print(f"note: {n}")
|
|
149
|
+
best = offers[0]
|
|
150
|
+
est = estimate(job, best.usd_h, throughput, offer=best)
|
|
151
|
+
est.update(provider=best.provider, gpu=best.gpu, where=best.where, usd_per_h=best.usd_h, ceiling=ceiling,
|
|
152
|
+
machine=best.ref.get("machine"), cap_usd=job.max_usd, max_hours=job.max_hours)
|
|
153
|
+
ledger.write("estimate", est, job=run_id, refs={"name": job.name, "workload": job.workload})
|
|
154
|
+
spent_today = ledger.spend_since(ledger.today_start())
|
|
155
|
+
print(f"{job.name}: {best.label} ({best.where}) at ${best.usd_h:.2f}/h; ~{est['wall_s'] / 60:.0f} min, ~${est['usd']:.3f}, "
|
|
156
|
+
f"{est.get('p_fail', 0):.0%} host risk ({est.get('basis_summary') or est.get('basis', '')}); cap ${job.max_usd}, {job.max_hours} h; "
|
|
157
|
+
f"today so far ${spent_today:.2f} of ${s['daily_cap_usd']}")
|
|
158
|
+
if spent_today + min(est["usd"], job.max_usd) > s["daily_cap_usd"]:
|
|
159
|
+
raise SystemExit(f"refusing: today's spend ${spent_today:.2f} + this job would exceed the daily cap ${s['daily_cap_usd']}")
|
|
160
|
+
if dry_run:
|
|
161
|
+
for o in offers[1:6]:
|
|
162
|
+
e = o.estimate
|
|
163
|
+
print(f" then {o.label} ({o.where}) ${o.usd_h:.2f}/h; ~{e.get('wall_s', 0) / 60:.0f} min, "
|
|
164
|
+
f"~${e.get('usd', 0):.3f}, {e.get('p_fail', 0):.0%} risk")
|
|
165
|
+
return run_id, None
|
|
166
|
+
|
|
167
|
+
log_dir = Path(log_dir or DATA / "jobs" / run_id)
|
|
168
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
log = open(log_dir / "run.log", "ab")
|
|
170
|
+
pubkey = ensure_key()
|
|
171
|
+
name = f"gipper-{job.name}-{run_id[-6:]}"[:50]
|
|
172
|
+
inst, outcome = None, "error"
|
|
173
|
+
stop = {"why": None}
|
|
174
|
+
old = signal.signal(signal.SIGINT, lambda *a: stop.update(why="interrupted"))
|
|
175
|
+
signal.signal(signal.SIGTERM, lambda *a: stop.update(why="terminated"))
|
|
176
|
+
try:
|
|
177
|
+
queue = list(offers)
|
|
178
|
+
for attempt in range(3):
|
|
179
|
+
prov, offer, made = _rent(queue, job, pubkey, name)
|
|
180
|
+
inst = {"provider": prov, "id": made["id"], "t": _now(), "usd_h": made["usd_h"] or offer.usd_h}
|
|
181
|
+
ledger.write("pod.create", {"provider": prov.name, "pod": made["id"], "gpu": made["gpu"], "where": offer.where,
|
|
182
|
+
"machine": offer.ref.get("machine"), "country": offer.country,
|
|
183
|
+
"usd_per_h": inst["usd_h"],
|
|
184
|
+
"deadline": inst["t"] + job.max_hours * 3600 + 600},
|
|
185
|
+
job=run_id, refs={"name": job.name})
|
|
186
|
+
print(f"{prov.name} {made['id']} created: {made['gpu']}, {offer.where}, ${inst['usd_h']:.3f}/h; waiting for ssh")
|
|
187
|
+
remote, info = None, {}
|
|
188
|
+
try:
|
|
189
|
+
remote, info = prov.wait_ready(made["id"], job)
|
|
190
|
+
if not remote.wait(600):
|
|
191
|
+
raise BadHost("ssh never came up")
|
|
192
|
+
ledger.write("pod.ready", {"provider": prov.name, "pod": made["id"], "after_s": round(_now() - inst["t"]),
|
|
193
|
+
"machine": info.get("machine"), "gpu": info.get("gpu"),
|
|
194
|
+
"ports": info.get("ports")}, job=run_id)
|
|
195
|
+
from gipper.latency import HERE, connect_ms
|
|
196
|
+
ledger.write("host.rtt", {"provider": prov.name, "pod": made["id"], "machine": info.get("machine"),
|
|
197
|
+
"origin": HERE, "ms": connect_ms(remote.host, remote.port),
|
|
198
|
+
"basis": "fastest of 5 TCP connects to the ssh port"}, job=run_id)
|
|
199
|
+
health = preflight(remote)
|
|
200
|
+
ledger.write("host.ok", {"pod": made["id"], **health}, job=run_id)
|
|
201
|
+
from gipper.cuda_probe import check as cuda_check, CudaProbeError
|
|
202
|
+
try:
|
|
203
|
+
cuda_health = cuda_check(remote, job.env)
|
|
204
|
+
except CudaProbeError as error:
|
|
205
|
+
ledger.write("host.cuda_probe", {"provider": prov.name, "pod": made["id"], **error.receipt}, job=run_id)
|
|
206
|
+
raise
|
|
207
|
+
ledger.write("host.cuda_probe", {"provider": prov.name, "pod": made["id"], **cuda_health}, job=run_id)
|
|
208
|
+
met = requirements(remote, job)
|
|
209
|
+
if met:
|
|
210
|
+
ledger.write("host.requirements", {"provider": prov.name, "pod": made["id"], **met}, job=run_id)
|
|
211
|
+
if job.placement.probe_transfer_mb:
|
|
212
|
+
from gipper.network_probe import probe
|
|
213
|
+
network = probe(remote, job.placement)
|
|
214
|
+
ledger.write("host.network_probe", {"provider": prov.name, "pod": made["id"],
|
|
215
|
+
"machine": info.get("machine"), **network}, job=run_id)
|
|
216
|
+
print(f"network probe from {network['origin_hostname']}: upload {network['upload_mbps']} Mbps, "
|
|
217
|
+
f"download {network['download_mbps']} Mbps ({network['status']}; includes SSH overhead)", flush=True)
|
|
218
|
+
if network['status'] != 'ok':
|
|
219
|
+
raise BadHost(network.get('error', 'network probe failed'))
|
|
220
|
+
break
|
|
221
|
+
except BadHost as e:
|
|
222
|
+
print(f"rejecting host {info.get('machine') or made['id']}: {e}")
|
|
223
|
+
ledger.write("host.reject", {"provider": prov.name, "pod": made["id"], "machine": info.get("machine"),
|
|
224
|
+
"why": str(e), "kind": "unsuitable" if isinstance(e, Unsuitable) else None},
|
|
225
|
+
job=run_id)
|
|
226
|
+
if not _terminate(inst, run_id, job):
|
|
227
|
+
raise CleanupPending("prior instance closure unverified; refusing replacement rental")
|
|
228
|
+
inst, remote = None, None
|
|
229
|
+
else:
|
|
230
|
+
raise RuntimeError("three hosts in a row were busy or broken")
|
|
231
|
+
jobdir = f"{remote.home}/job"
|
|
232
|
+
ports = info.get("ports") or {}
|
|
233
|
+
for k, v in ports.items():
|
|
234
|
+
print(f"port {k} -> {v['host']}:{v['port']}")
|
|
235
|
+
print(f"ssh ready after {_now() - inst['t']:.0f}s; uploading inputs")
|
|
236
|
+
|
|
237
|
+
remote.run(f"mkdir -p {jobdir}")
|
|
238
|
+
remote.put(BOOTSTRAP, f"{jobdir}/.gipper/bootstrap.sh")
|
|
239
|
+
t_up, up_bytes = _now(), round(input_gb(job) * 1e9)
|
|
240
|
+
for t in job.inputs:
|
|
241
|
+
remote.put(t.src, f"{jobdir}/{t.dst}", t.exclude)
|
|
242
|
+
_transfer(run_id, prov.name, inst["id"], info, "up", up_bytes, _now() - t_up)
|
|
243
|
+
# where the outside world reaches the job's ports (e.g. to advertise in ICE candidates)
|
|
244
|
+
env = {"GIPPER_PUBLIC_IP": remote.host, **{f"GIPPER_PORT_{k.replace('/', '_').upper()}": str(v["port"])
|
|
245
|
+
for k, v in ports.items()}, **job.env, **cuda_health["environment_override"]}
|
|
246
|
+
envs = " ".join(f"export {k}={shlex.quote(v)};" for k, v in env.items())
|
|
247
|
+
body = f"source {jobdir}/.gipper/bootstrap.sh; {envs} cd {jobdir}; " + \
|
|
248
|
+
(f"{job.setup} && " if job.setup else "") + f"{job.command}"
|
|
249
|
+
limit = int(job.max_hours * 3600)
|
|
250
|
+
# Only the job is backgrounded, with every stream redirected, so this ssh returns at
|
|
251
|
+
# once (backgrounding the whole `cd && nohup` list kept ssh's stdout open until the
|
|
252
|
+
# job ended). setsid detaches it from the ssh session entirely.
|
|
253
|
+
job_cmd = f"timeout {limit} bash -c {shlex.quote(body)}; echo $? > {jobdir}/.gipper/exit"
|
|
254
|
+
remote.run(f"cd {jobdir}; setsid nohup bash -c {shlex.quote(job_cmd)} > {jobdir}/.gipper/run.log 2>&1 < /dev/null &",
|
|
255
|
+
timeout=60)
|
|
256
|
+
ledger.write("run.start", {"provider": prov.name, "pod": inst["id"]}, job=run_id)
|
|
257
|
+
t_run = _now()
|
|
258
|
+
print("running; log follows\n" + "-" * 60, flush=True)
|
|
259
|
+
|
|
260
|
+
offset, last_ck, code = 0, _now(), None
|
|
261
|
+
while code is None:
|
|
262
|
+
if stop["why"]:
|
|
263
|
+
raise KeyboardInterrupt(stop["why"])
|
|
264
|
+
spent = (_now() - inst["t"]) / 3600 * inst["usd_h"]
|
|
265
|
+
if spent >= job.max_usd or _now() - inst["t"] >= job.max_hours * 3600:
|
|
266
|
+
raise Capped(f"${spent:.2f} / {(_now() - inst['t']) / 60:.0f} min reached the job's cap")
|
|
267
|
+
st = remote.run(f"cat {jobdir}/.gipper/exit 2>/dev/null || echo running", check=False, timeout=60)
|
|
268
|
+
chunk = remote.run(f"tail -c +{offset + 1} {jobdir}/.gipper/run.log", check=False, timeout=60)
|
|
269
|
+
if chunk.returncode == 0 and chunk.stdout:
|
|
270
|
+
sys.stdout.write(chunk.stdout)
|
|
271
|
+
sys.stdout.flush()
|
|
272
|
+
data = chunk.stdout.encode()
|
|
273
|
+
log.write(data)
|
|
274
|
+
offset += len(data)
|
|
275
|
+
status = st.stdout.strip() if st.returncode == 0 else "running"
|
|
276
|
+
if status.lstrip("-").isdigit():
|
|
277
|
+
code = int(status)
|
|
278
|
+
break
|
|
279
|
+
if job.checkpoint_src and _now() - last_ck >= job.checkpoint_every_s:
|
|
280
|
+
for t in job.outputs:
|
|
281
|
+
if t.src == job.checkpoint_src:
|
|
282
|
+
remote.get(f"{jobdir}/{t.src}", t.dst)
|
|
283
|
+
ledger.write("checkpoint", {"src": job.checkpoint_src}, job=run_id)
|
|
284
|
+
last_ck = _now()
|
|
285
|
+
time.sleep(10)
|
|
286
|
+
compute_s = round(_now() - t_run)
|
|
287
|
+
print("-" * 60 + f"\nexit {code}; collecting outputs")
|
|
288
|
+
t_down, before = _now(), sum(_bytes(t.dst) for t in job.outputs)
|
|
289
|
+
for t in job.outputs:
|
|
290
|
+
remote.get(f"{jobdir}/{t.src}", t.dst)
|
|
291
|
+
if not job.checkpoint_src: # with checkpoints, the final pull re-sends files already counted
|
|
292
|
+
_transfer(run_id, prov.name, inst["id"], info, "down", sum(_bytes(t.dst) for t in job.outputs) - before,
|
|
293
|
+
_now() - t_down)
|
|
294
|
+
remote.get(f"{jobdir}/.gipper/run.log", str(log_dir / "pod"))
|
|
295
|
+
ledger.write("collect", {"outputs": [t.dst for t in job.outputs]}, job=run_id)
|
|
296
|
+
outcome = "succeeded" if code == 0 else ("timeout" if code == 124 else "failed")
|
|
297
|
+
except Capped as e:
|
|
298
|
+
outcome = "capped"
|
|
299
|
+
ledger.write("cap", {"reason": str(e)}, job=run_id)
|
|
300
|
+
print(f"\nstopping: {e}")
|
|
301
|
+
_salvage(locals().get("remote"), job, locals().get("jobdir"))
|
|
302
|
+
except KeyboardInterrupt as e:
|
|
303
|
+
outcome = "interrupted"
|
|
304
|
+
print(f"\nstopping: {e or 'interrupted'}")
|
|
305
|
+
_salvage(locals().get("remote"), job, locals().get("jobdir"))
|
|
306
|
+
except (ProviderError, RuntimeError, OSError) as e:
|
|
307
|
+
ledger.write("error", {"error": str(e)[:500]}, job=run_id)
|
|
308
|
+
print(f"\nerror: {e}")
|
|
309
|
+
_salvage(locals().get("remote"), job, locals().get("jobdir"))
|
|
310
|
+
finally:
|
|
311
|
+
try:
|
|
312
|
+
signal.signal(signal.SIGINT, old)
|
|
313
|
+
if inst:
|
|
314
|
+
try:
|
|
315
|
+
if inst.get("termination_pending") or not _terminate(inst, run_id, job):
|
|
316
|
+
outcome = "termination_pending"
|
|
317
|
+
except Exception as cleanup_error:
|
|
318
|
+
outcome = "termination_pending"
|
|
319
|
+
ledger.write("pod.termination_pending", {"provider": inst["provider"].name, "pod": inst["id"],
|
|
320
|
+
"reason": str(cleanup_error)[:500]}, job=run_id)
|
|
321
|
+
finally:
|
|
322
|
+
try:
|
|
323
|
+
ledger.write("run.end", {"outcome": outcome, "units": job.units_count, "units_name": job.units_name,
|
|
324
|
+
"compute_s": locals().get("compute_s")},
|
|
325
|
+
job=run_id, refs={"name": job.name, "workload": job.workload})
|
|
326
|
+
finally:
|
|
327
|
+
log.close()
|
|
328
|
+
return run_id, outcome
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _transfer(run_id, provider, pod, info, direction, nbytes, seconds):
|
|
332
|
+
"""Timed real transfer: what gipper.history learns link speeds from."""
|
|
333
|
+
if nbytes > 0 and seconds > 0:
|
|
334
|
+
ledger.write("transfer", {"provider": provider, "pod": pod, "machine": info.get("machine"),
|
|
335
|
+
"direction": direction, "bytes": nbytes, "seconds": round(seconds, 2),
|
|
336
|
+
"mbps": round(nbytes * 8 / 1e6 / seconds, 1)}, job=run_id)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _terminate(inst, run_id, job):
|
|
340
|
+
from gipper.lifecycle import terminate
|
|
341
|
+
if inst.get("closure_verified"):
|
|
342
|
+
return True
|
|
343
|
+
result = terminate(inst["provider"], inst["id"], job_id=run_id, started=inst["t"], usd_h=inst["usd_h"],
|
|
344
|
+
refs={"name": job.name, "workload": job.workload})
|
|
345
|
+
inst["closure_verified"] = result
|
|
346
|
+
inst["termination_pending"] = not result
|
|
347
|
+
return result
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _salvage(remote, job, jobdir):
|
|
351
|
+
"""Bring back whatever outputs exist before the machine goes."""
|
|
352
|
+
if not remote or not jobdir:
|
|
353
|
+
return
|
|
354
|
+
for t in job.outputs:
|
|
355
|
+
try:
|
|
356
|
+
remote.get(f"{jobdir}/{t.src}", t.dst)
|
|
357
|
+
except Exception as e: # salvage is best effort; teardown must still run
|
|
358
|
+
print(f"salvage of {t.src} failed: {e}")
|
gipper/runpod/api.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Old import path for the Runpod client: `from gipper.runpod.api import Api`.
|
|
2
|
+
Guards and spend collectors written before 2026-10-26 keep working; new code uses
|
|
3
|
+
`gipper.providers.runpod.Runpod().api()` or `gipper.providers.load(name)`."""
|
|
4
|
+
from gipper.config import runpod_key
|
|
5
|
+
from gipper.providers.runpod import Api as _Api, RunpodError # noqa: F401
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Api(_Api):
|
|
9
|
+
def __init__(self, key=None, timeout=60):
|
|
10
|
+
super().__init__(key or runpod_key())
|
gipper/runpod/run.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Old import path `gipper.runpod.run`. Only `_wait_ready` is kept, for inspection
|
|
2
|
+
scripts. Everything else moved to `gipper.run` and works across providers: `plan()`
|
|
3
|
+
replaced `pick_gpu()`. Asking for any other name here fails loudly, so a script that
|
|
4
|
+
monkeypatches the old selection can't silently patch nothing."""
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from gipper.ssh import Remote
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _wait_ready(api, pod_id, timeout_s=900):
|
|
11
|
+
"""(pod, Remote) once the Runpod pod has a public IP and a mapped ssh port."""
|
|
12
|
+
t0 = time.time()
|
|
13
|
+
while time.time() - t0 < timeout_s:
|
|
14
|
+
p = api.get_pod(pod_id)
|
|
15
|
+
port = (p.get("portMappings") or {}).get("22")
|
|
16
|
+
if p.get("publicIp") and port:
|
|
17
|
+
return p, Remote(p["publicIp"], port)
|
|
18
|
+
time.sleep(5)
|
|
19
|
+
raise RuntimeError(f"pod {pod_id} had no ssh endpoint after {timeout_s}s")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def __getattr__(name):
|
|
23
|
+
raise AttributeError(f"gipper.runpod.run.{name} is gone: use `from gipper import run` "
|
|
24
|
+
"(run.plan() replaced pick_gpu; run.run(job, provider=...) picks the market)")
|
gipper/ssh.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""ssh to a rented machine with gipper's own key; files move as tar streams over the same
|
|
2
|
+
connection (no rsync or Runpod CLI needed on either side)."""
|
|
3
|
+
import shlex
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from gipper.config import KEY_DIR
|
|
9
|
+
|
|
10
|
+
KEY = KEY_DIR / "id_ed25519"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def ensure_key():
|
|
14
|
+
if not KEY.exists():
|
|
15
|
+
KEY.parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "gipper", "-f", str(KEY)], check=True)
|
|
17
|
+
return KEY.with_suffix(".pub").read_text().strip()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Remote:
|
|
21
|
+
def __init__(self, host, port, user="root"):
|
|
22
|
+
self.host, self.port, self.user = host, int(port), user
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def home(self):
|
|
26
|
+
"""Containers log in as root; VMs (Shadeform) as an ordinary user."""
|
|
27
|
+
return "/root" if self.user == "root" else f"/home/{self.user}"
|
|
28
|
+
|
|
29
|
+
def _base(self):
|
|
30
|
+
return ["ssh", "-i", str(KEY), "-p", str(self.port), "-o", "StrictHostKeyChecking=no",
|
|
31
|
+
"-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", "-o", "ServerAliveInterval=30",
|
|
32
|
+
"-o", "ConnectTimeout=15", f"{self.user}@{self.host}"]
|
|
33
|
+
|
|
34
|
+
def run(self, cmd, check=True, capture=True, timeout=None):
|
|
35
|
+
r = subprocess.run(self._base() + [cmd], capture_output=capture, text=True, timeout=timeout)
|
|
36
|
+
if check and r.returncode:
|
|
37
|
+
raise RuntimeError(f"ssh `{cmd[:80]}` exited {r.returncode}: {(r.stderr or '')[-400:]}")
|
|
38
|
+
return r
|
|
39
|
+
|
|
40
|
+
def wait(self, timeout_s=600):
|
|
41
|
+
t0 = time.time()
|
|
42
|
+
while time.time() - t0 < timeout_s:
|
|
43
|
+
try:
|
|
44
|
+
if self.run("true", check=False, timeout=30).returncode == 0:
|
|
45
|
+
return True
|
|
46
|
+
except subprocess.TimeoutExpired:
|
|
47
|
+
pass
|
|
48
|
+
time.sleep(5)
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
def put(self, src, dst, exclude=()):
|
|
52
|
+
"""Local file or directory -> remote path (created)."""
|
|
53
|
+
src = Path(src)
|
|
54
|
+
ex = [f"--exclude={e}" for e in exclude]
|
|
55
|
+
if src.is_dir():
|
|
56
|
+
tar = ["tar", "czf", "-", *ex, "-C", str(src), "."]
|
|
57
|
+
remote = f"mkdir -p {shlex.quote(dst)} && tar xzf - -C {shlex.quote(dst)}"
|
|
58
|
+
else:
|
|
59
|
+
tar = ["tar", "czf", "-", "-C", str(src.parent), src.name]
|
|
60
|
+
parent = str(Path(dst).parent)
|
|
61
|
+
remote = (f"mkdir -p {shlex.quote(parent)} && tar xzf - -C {shlex.quote(parent)}"
|
|
62
|
+
f" && [ {shlex.quote(src.name)} = {shlex.quote(Path(dst).name)} ] || mv {shlex.quote(parent + '/' + src.name)} {shlex.quote(dst)}")
|
|
63
|
+
p1 = subprocess.Popen(tar, stdout=subprocess.PIPE)
|
|
64
|
+
p2 = subprocess.run(self._base() + [remote], stdin=p1.stdout, capture_output=True, text=True)
|
|
65
|
+
p1.stdout.close()
|
|
66
|
+
if p1.wait() or p2.returncode:
|
|
67
|
+
raise RuntimeError(f"upload {src} -> {dst} failed: {p2.stderr[-400:]}")
|
|
68
|
+
|
|
69
|
+
def get(self, src, dst):
|
|
70
|
+
"""Remote directory (or file) -> local directory dst (merged, overwriting)."""
|
|
71
|
+
Path(dst).mkdir(parents=True, exist_ok=True)
|
|
72
|
+
if self.run(f"test -e {shlex.quote(src)}", check=False, timeout=60).returncode:
|
|
73
|
+
return False # nothing there (yet): not an error
|
|
74
|
+
remote = (f"if [ -d {shlex.quote(src)} ]; then tar czf - -C {shlex.quote(src)} .; "
|
|
75
|
+
f"elif [ -e {shlex.quote(src)} ]; then tar czf - -C $(dirname {shlex.quote(src)}) $(basename {shlex.quote(src)}); fi")
|
|
76
|
+
p1 = subprocess.Popen(self._base() + [remote], stdout=subprocess.PIPE)
|
|
77
|
+
p2 = subprocess.run(["tar", "xzf", "-", "-C", str(dst)], stdin=p1.stdout, capture_output=True, text=True)
|
|
78
|
+
p1.stdout.close()
|
|
79
|
+
p1.wait()
|
|
80
|
+
if p2.returncode and "empty" not in p2.stderr:
|
|
81
|
+
raise RuntimeError(f"download {src} -> {dst} failed: {p2.stderr[-400:]}")
|
|
82
|
+
return True
|