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/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}")
@@ -0,0 +1,2 @@
1
+ """Compatibility for scripts written before gipper had more than one provider
2
+ (2026-10-26). New code: `gipper.providers.runpod`, `from gipper import run`."""
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