igneum/tools/fleet/lib/standing.py

114 lines
10 KiB
Python

"""The standing fleet (Josh's ruling, 6 October 2026, 19:50 UK): rented cards that stay up and are never destroyed on a
job's end. A box is standing when its registry row carries standing=true and a role: live (the live devnet: node, miner,
prover, voter) or dn2 (Devnet 2: seed, miner or prover). This module is the Mac side of the rule; box-standing.sh is the
box side (the supervisor loop). Every call goes through lib.box.
roster() the standing rows with role, card, price and uptime
install(label) puts box-standing.sh (and the recovery recipe) on the box and starts the supervisor once
check() one pass: ssh alive? supervisor up? node synced? exec moving? miner and prover present?
version against the live manifest (dl.igneum.network/dl/public/igneum-downloads.json,
files.miner-hive.version); returns one dict per box and writes ~/Desktop/fleet/standing.jsonl
update(label, pkg) the version follow: the box downloads the manifest's hive package (sha256 checked), unpacks it
to /opt/igneum/pkg.new, swaps it in and writes /root/fleet/standing.node; the supervisor
restarts the node on it (data dir kept, seconds)
rerent(label) the host died (ssh dead for two checks, or the provider says the instance is gone): rent the
same shape (card, VRAM, provider) with the label suffixed "-r<n>", set it up, install the
supervisor, mark the old row destroyed; the Devnet 2 roles take their seed from the registry
loop(every=600) check, then rerent what died and update what is behind, every ten minutes
Nothing here destroys a standing box; destroy() in lib.box stays for the one-shot boxes.
"""
import os, sys, json, time, datetime, subprocess
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from lib.box import Box, Registry, F, SshError
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ROOT = os.path.expanduser("~/Desktop/fleet"); MANIFEST = "https://dl.igneum.network/dl/public/igneum-downloads.json"
def now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def roster():
reg = Registry.load(); rows = []
for iid, b in reg.items():
if not b.get("standing") or b.get("state") == "destroyed": continue
since = b.get("standing_since") or b.get("rented_at") or now()
up_h = (datetime.datetime.now(datetime.timezone.utc) - datetime.datetime.fromisoformat(since.replace("Z", "+00:00"))).total_seconds() / 3600
rows.append({"iid": iid, "label": b["label"], "role": b.get("role", "live"), "card": b.get("card"), "provider": b.get("provider"), "dph": round(float(b.get("dph") or 0), 3), "uptime_h": round(up_h, 1), "since": since})
return sorted(rows, key=lambda r: (r["role"], r["label"]))
def manifest():
try: return json.loads(subprocess.run(["curl", "-fsSL", "-m", "20", MANIFEST], capture_output=True, text=True, timeout=30).stdout)["files"]["miner-hive"]
except Exception: return {}
def install(label, role=None, prover=None, mine=None):
"""Mark the row standing and start the supervisor on the box; idempotent (a running supervisor is left alone)."""
iid, b = Registry.find(label); role = role or b.get("role", "live"); b_ = Box.from_registry(label)
prover = "1" if (prover if prover is not None else role == "live" or b.get("dn2_prover")) else "0"
mine = "1" if (mine if mine is not None else not b.get("dn2_seed")) else "0"
b_.put([os.path.join(HERE, "box-standing.sh"), os.path.join(HERE, "box-exec-snapshot.sh")], f"{F}/in/")
hub = next((v for v in Registry.load().values() if v.get("hub") and v.get("state") != "destroyed"), {})
seed = b.get("dn2_seed_addr", "")
rc, out, err = b_.run(f"cd {F} && chmod +x in/box-standing.sh in/box-exec-snapshot.sh; pgrep -f '^bash in/box-standing.sh' >/dev/null && echo supervisor-already || {{ ROLE={role} LABEL={label} WALLET={b.get('wallet')} PROVER={prover} MINE={mine} HUB_PEER={hub.get('hub_peer','')} SEED={seed} setsid nohup bash in/box-standing.sh </dev/null >/dev/null 2>&1 & sleep 2; echo supervisor-started; }}", 60)
Registry.patch(iid, standing=True, role=role, standing_since=b.get("standing_since") or now(), doing=f"standing {role}: node, {'miner, ' if mine == '1' else ''}{'prover' if prover == '1' else 'no prover'} (supervised)")
return out.strip()
def check_one(r):
b = Box.from_registry(r["label"]); row = dict(r); row["t"] = now()
rc, out, err = b.run("echo alive; pgrep -fc '^bash in/box-standing.sh'; tail -1 /root/fleet/out/standing.log 2>/dev/null | cut -c1-400", 45)
if rc != 0 or "alive" not in out: row.update(alive=False); return row
lines = out.strip().split("\n"); row.update(alive=True, supervisor=int(lines[1] or 0) if len(lines) > 1 else 0, last=lines[2] if len(lines) > 2 else "")
kv = dict(x.split("=", 1) for x in row["last"].split() if "=" in x and not x.startswith("miner="))
row.update(blocks=int(kv.get("blocks", 0) or 0), synced=kv.get("synced") == "true", exec_tip=int(kv.get("exec", 0) or 0), version=kv.get("version", ""), node_pid=kv.get("node", ""), prover=int(kv.get("prover", 0) or 0), bin=kv.get("bin", ""))
row["bin_sha16"] = os.path.basename(row["bin"]).replace("igneumd-", "") if "igneumd-" in row["bin"] and len(os.path.basename(row["bin"])) == 24 else ""
return row
def check(write=True):
from concurrent.futures import ThreadPoolExecutor
rows = roster(); m = manifest(); want = m.get("version", "")
with ThreadPoolExecutor(16) as ex: res = list(ex.map(check_one, rows))
reg = Registry.load()
for r in res:
r["manifest_version"] = want; wanted = (reg.get(r["iid"]) or {}).get("node_sha16_wanted")
r["behind"] = bool(wanted and r.get("bin_sha16") and r["bin_sha16"] != wanted) # only an explicit publish sets the want; the package version string is not a node version
if write:
with open(os.path.join(ROOT, "standing.jsonl"), "a") as f:
for r in res: f.write(json.dumps(r) + "\n")
return res
def update(label):
"""The version follow: the manifest's hive package onto the box, the node pointer rewritten, the supervisor restarts it."""
m = manifest(); b = Box.from_registry(label)
if not m: raise SshError("no manifest")
rc, out, err = b.run(f"set -e; cd {F}; curl -fsSL -m 600 -o pkg-{m['version']}.tgz https://dl.igneum.network{m['path']}; echo '{m['sha256']} pkg-{m['version']}.tgz' | sha256sum -c - >/dev/null; rm -rf /opt/igneum/pkg.new; mkdir -p /opt/igneum/pkg.new; tar -C /opt/igneum/pkg.new --strip-components=1 -xzf pkg-{m['version']}.tgz; rm -rf /opt/igneum/pkg.prev; mv /opt/igneum/pkg /opt/igneum/pkg.prev; mv /opt/igneum/pkg.new /opt/igneum/pkg; echo /opt/igneum/pkg/bin/igneumd > {F}/standing.node; pkill -9 -f '^/opt/igneum/pkg/bin/igneumd' 2>/dev/null; pkill -9 -f '^/opt/igneum/pkg.prev/bin/igneumd' 2>/dev/null; echo updated-to-{m['version']}", 900)
iid, _ = Registry.find(label); Registry.patch(iid, version_wanted=m["version"], updated_at=now()); return out.strip()
def rerent(label):
"""Same shape again on the same provider; the old row is marked destroyed. Returns the new label or None."""
import fleet
iid, b = Registry.find(label); n = int(b.get("rerents", 0)) + 1; new = f"{label.split('-r')[0]}-r{n}"
if b.get("provider") == "runpod":
import runpod; d = runpod.rent(b.get("gpu_type_id") or f"NVIDIA GeForce {b.get('card')}", new, count=1, disk=40)
if not d.get("id"): return None
reg = fleet.load(); reg[str(d["id"])] = {**{k: b[k] for k in ("card", "num_gpus", "provider", "phase", "wallet", "role", "standing") if k in b}, "label": new, "dph": d.get("costPerHr") or b.get("dph"), "state": "renting", "rented_at": now(), "standing_since": now(), "rerents": n, "rerent_of": label}; fleet.save(reg)
else:
nid = fleet.rent_card(b.get("card"), new, b.get("archs") or [], min_ram=b.get("vram_mb"), phase=b.get("phase", "2"))
if not nid: return None
Registry.patch(str(nid), standing=True, role=b.get("role", "live"), standing_since=now(), rerents=n, rerent_of=label)
Registry.patch(iid, state="destroyed", destroyed_at=now(), destroyed_reason="host died, re-rented as " + new)
return new
def loop(every=600):
dead = {}
while True:
try:
res = check()
for r in res:
if not r["alive"]:
dead[r["label"]] = dead.get(r["label"], 0) + 1
if dead[r["label"]] >= 2: print(now(), "re-renting", r["label"], "->", rerent(r["label"]), flush=True); dead.pop(r["label"], None)
else:
dead.pop(r["label"], None)
if r["behind"]: print(now(), "behind:", r["label"], r.get("bin_sha16"), "wanted", (Registry.load().get(r["iid"]) or {}).get("node_sha16_wanted"), "(a publish script moves it; the loop only reports)", flush=True)
print(now(), "standing check:", len(res), "boxes,", sum(1 for r in res if r["alive"]), "alive,", sum(1 for r in res if r.get("synced")), "synced,", sum(1 for r in res if r["behind"]), "behind", flush=True)
except Exception as e: print(now(), "loop error", str(e)[:200], flush=True)
time.sleep(every)
if __name__ == "__main__":
a = sys.argv[1:]
if not a or a[0] == "roster":
for r in roster(): print(f"{r['label']:<12} {r['role']:<5} {str(r['card']):<22} {r['provider']:<7} USD {r['dph']:.3f}/h up {r['uptime_h']:.1f} h")
rs = roster(); print(f"{len(rs)} standing boxes, USD {sum(r['dph'] for r in rs):.2f}/h, USD {24*sum(r['dph'] for r in rs):.0f}/day")
elif a[0] == "install": print(install(a[1], *(a[2:3] or [None])))
elif a[0] == "check":
for r in check(): print({k: r.get(k) for k in ("label", "role", "alive", "supervisor", "blocks", "synced", "exec_tip", "version", "behind", "prover")})
elif a[0] == "update": print(update(a[1]))
elif a[0] == "rerent": print(rerent(a[1]))
elif a[0] == "loop": loop(int(a[1]) if len(a) > 1 else 600)