File size: 19,369 Bytes
6570567 ad0694f 6570567 ad0694f 6570567 2b7ab0e ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 ad0694f 6570567 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 | #!/usr/bin/env python3
"""The vault fly's updater: pull signed releases from Hugging Face and apply them (plan phase 7).
Runs on the HOST (root, systemd timer, standard library only), never inside the container, because it rebuilds and
restarts the container. Everything it trusts comes from files on this host that no release can change:
``/etc/fly/allowed_signers`` (Bryce's release keys, restricted to the release namespace) and ``/etc/fly/update.json``
(which also pins the very first release by its manifest hash: no trust on first use).
One round:
1. poll the HF model repo's commit sha; nothing to do if it is the installed one
2. fetch ``release.json`` + ``release.json.sig`` from that immutable commit; verify the signature with
``ssh-keygen -Y verify``; require ``seq`` to grow and ``prev_sha256`` to be the installed manifest (no rollback,
no forks)
3. download exactly the listed paths (strict path check), verify size + sha256 of each, into a fresh directory
4. compare file classes with the installed release:
only model/seed -> ``docker compose exec fly fly-trader apply-release /releases/<seq>`` (hot swap; models
cannot run code, fly_trader/train/model_io.py); unhealthy after it -> the previous release's
models are re-applied
code -> held for ``code_delay_s`` (24 h) with a Telegram notice, so a stolen signing key cannot ship
code faster than Bryce can ``veto`` it; ``approve`` applies it at once. Then: wait for
``ready-for-restart``, build ``fly-trader:<seq>`` from that directory, recreate, wait for
healthy, else roll back to the previous tag and compose file
infra -> held until ``approve`` (compose/Dockerfile/entrypoint can grant host access, so a human
looks first)
fly_update.py run | approve <seq> | veto <seq> | status | restart | verify <dir> <allowed_signers> [principal ...]
``restart`` recreates the containers on the installed release (after you edit /srv/fly/*.env).
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.request
from pathlib import Path
CONF = Path(os.environ.get("FLY_UPDATE_CONF", "/etc/fly/update.json"))
PATH_RE = re.compile(r"^(?!.*\.\.)(?!/)[A-Za-z0-9_.\-/]+$")
INFRA = ("Dockerfile", "docker-compose.yml", "docker-compose.cuda.yml", "docker-compose.rocm.yml", ".dockerignore", "docker/", "deploy/")
# what models/ and seed/ may hold: nothing a loader could execute (fly_trader/train/model_io.SAFE_SUFFIXES)
MODEL_SUFFIXES = {".pt", ".skops", ".npz", ".json", ".bin", ".parquet", ".txt", ".sha256", ""}
SEED_SUFFIXES = {".json", ".sql"}
def conf() -> dict:
c = json.loads(CONF.read_text())
c.setdefault("repo", "bryceweiner/fly-trader")
c.setdefault("allowed_signers", "/etc/fly/allowed_signers")
c.setdefault("principals", [c.get("principal") or "bryce", "bryce-recovery"])
c.setdefault("code_delay_s", 86400)
c.setdefault("namespace", "fly-trader-release")
c.setdefault("root", "/srv/fly")
c.setdefault("project", "fly")
return c
def log(msg: str) -> None:
print(time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), msg, flush=True)
def alert(c: dict, text: str) -> None:
log("ALERT " + text)
tok, chat = c.get("telegram_bot_token"), c.get("telegram_chat_id")
if not tok or not chat:
return
try:
body = json.dumps({"chat_id": chat, "text": "[fly updater] " + text}).encode()
urllib.request.urlopen(urllib.request.Request(f"https://api.telegram.org/bot{tok}/sendMessage", data=body,
headers={"Content-Type": "application/json"}), timeout=10)
except Exception as e:
log(f"telegram failed: {type(e).__name__}")
def http(url: str, dest: Path | None = None, timeout: int = 120) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": "fly-updater/1"})
with urllib.request.urlopen(req, timeout=timeout) as r:
if dest is None:
return r.read()
with open(dest, "wb") as f:
shutil.copyfileobj(r, f, 1 << 20)
return b""
def sha256_file(p: Path) -> str:
h = hashlib.sha256()
with open(p, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def classify(path: str) -> str:
if path.startswith("models/"):
return "model"
if path.startswith("seed/"):
return "seed"
if path in INFRA or any(path.startswith(p) for p in INFRA if p.endswith("/")):
return "infra"
return "code"
def verify_signature(c: dict, manifest: bytes, sig: bytes) -> str:
"""The principal whose key signed ``manifest`` (any of ``principals``), else SystemExit."""
principals = c.get("principals") or [c["principal"]]
err = ""
with tempfile.TemporaryDirectory() as d:
sp = Path(d) / "release.json.sig"; sp.write_bytes(sig)
for who in principals:
r = subprocess.run(["ssh-keygen", "-Y", "verify", "-f", c["allowed_signers"], "-I", who, "-n", c["namespace"], "-s", str(sp)],
input=manifest, capture_output=True)
if r.returncode == 0:
return who
err = r.stderr.decode(errors="replace").strip()
raise SystemExit(f"release signature does NOT verify: {err[:300]}")
def check_manifest(m: dict) -> None:
if m.get("schema") != 1 or not isinstance(m.get("seq"), int) or not isinstance(m.get("files"), list):
raise SystemExit("malformed release manifest")
for f in m["files"]:
p = f.get("path", "")
if not PATH_RE.match(p) or p.startswith(".git") or "//" in p:
raise SystemExit(f"refusing path {p!r}")
if f.get("class") != classify(p):
raise SystemExit(f"{p}: class {f.get('class')} does not match {classify(p)}")
if f["class"] == "model" and Path(p).suffix not in MODEL_SUFFIXES or f["class"] == "seed" and Path(p).suffix not in SEED_SUFFIXES:
raise SystemExit(f"{p}: file type not allowed in {f['class']}/ (a model or seed must not be able to run code)")
if not re.fullmatch(r"[0-9a-f]{64}", f.get("sha256", "")) or not isinstance(f.get("size"), int):
raise SystemExit(f"{p}: bad hash or size")
def verify_dir(d: Path, m: dict) -> None:
"""Exactly the listed files, each with its size and hash (no extra files may ride along into a build)."""
listed = {f["path"] for f in m["files"]}
for p in d.rglob("*"):
if p.is_symlink():
raise SystemExit(f"symlink in release: {p}")
rel = p.relative_to(d).as_posix()
if p.is_file() and rel not in listed and rel not in ("release.json", "release.json.sig"):
raise SystemExit(f"unlisted file in release: {rel}")
for f in m["files"]:
p = d / f["path"]
if not p.is_file() or p.stat().st_size != f["size"] or sha256_file(p) != f["sha256"]:
raise SystemExit(f"{f['path']}: missing or hash/size mismatch")
def state_path(c: dict) -> Path:
return Path(c["root"]) / "state.json"
def load_state(c: dict) -> dict:
p = state_path(c)
return json.loads(p.read_text()) if p.exists() else {"seq": 0, "manifest_sha256": None, "hf_sha": None, "image": None}
def save_state(c: dict, st: dict) -> None:
p = state_path(c); tmp = p.with_suffix(".tmp"); tmp.write_text(json.dumps(st, indent=1)); tmp.replace(p)
def compose(c: dict, *args: str, check: bool = True, env: dict | None = None) -> subprocess.CompletedProcess:
"""docker compose on the installed release (FLY_RELEASE = the image tag this host runs, unless ``env`` says otherwise)."""
installed_tag = load_state(c).get("image")
e = {**os.environ, "FLY_RELEASE": str(installed_tag) if installed_tag is not None else "none", **(env or {})}
return subprocess.run(["docker", "compose", "-p", c["project"], "-f", str(Path(c["root"]) / "docker-compose.yml"), *args],
cwd=c["root"], check=check, capture_output=True, text=True, env=e)
def fetch(c: dict, sha: str) -> tuple[dict, Path, bytes, str]:
base = f"https://huggingface.co/{c['repo']}/resolve/{sha}/"
raw = http(base + "release.json"); sig = http(base + "release.json.sig")
who = verify_signature(c, raw, sig)
m = json.loads(raw)
check_manifest(m)
dest = Path(c["root"]) / "releases" / str(m["seq"])
if dest.exists():
try: # already downloaded (a held or retried release): re-verify, reuse
if (dest / "release.json").read_bytes() == raw:
verify_dir(dest, m)
return m, dest, raw, who
except (SystemExit, OSError):
pass
shutil.rmtree(dest)
part = dest.with_name(dest.name + ".part")
if part.exists():
shutil.rmtree(part)
part.mkdir(parents=True)
for f in m["files"]:
out = part / f["path"]; out.parent.mkdir(parents=True, exist_ok=True)
http(base + f["path"], out, timeout=1800)
(part / "release.json").write_bytes(raw); (part / "release.json.sig").write_bytes(sig)
verify_dir(part, m)
part.rename(dest)
return m, dest, raw, who
def installed_manifest(c: dict, st: dict) -> dict | None:
if not st.get("seq"):
return None
p = Path(c["root"]) / "releases" / str(st["seq"]) / "release.json"
return json.loads(p.read_text()) if p.exists() else None
def changed_classes(old: dict | None, new: dict) -> set[str]:
if old is None:
return {"code", "infra", "model", "seed"}
o = {f["path"]: f["sha256"] for f in old["files"]}
n = {f["path"]: f["sha256"] for f in new["files"]}
paths = {p for p in set(o) | set(n) if o.get(p) != n.get(p)}
return {classify(p) for p in paths}
def wait_ready(c: dict, limit_s: int = 900) -> bool:
t0 = time.time()
while time.time() - t0 < limit_s:
if compose(c, "exec", "-T", "fly", "fly-trader", "ready-for-restart", check=False).returncode == 0:
return True
time.sleep(15)
return False
def healthy(c: dict, limit_s: int = 900) -> bool:
t0 = time.time()
while time.time() - t0 < limit_s:
cid = compose(c, "ps", "-q", "fly", check=False).stdout.strip()
if cid:
st = subprocess.run(["docker", "inspect", "-f", "{{.State.Health.Status}}", cid], capture_output=True, text=True).stdout.strip()
if st == "healthy":
return True
if st == "unhealthy":
return False
time.sleep(15)
return False
def deploy_code(c: dict, st: dict, m: dict, d: Path, infra: bool) -> str:
"""'ok', 'busy' (not ready for a restart: retried next round) or 'failed' (rolled back)."""
tag = f"fly-trader:{m['seq']}"
root = Path(c["root"]); live, backup = root / "docker-compose.yml", root / "docker-compose.yml.prev"
try: # the brain (default stage) and the key-holding signer, same release
subprocess.run(["docker", "build", "-t", tag, str(d)], check=True, capture_output=True)
subprocess.run(["docker", "build", "--target", "signer", "-t", f"fly-signer:{m['seq']}", str(d)], check=True, capture_output=True)
except subprocess.CalledProcessError as e:
alert(c, f"release {m['seq']}: image build failed: {(e.stderr or b'').decode(errors='replace')[-300:]}")
return "failed"
if st.get("image") is not None and not wait_ready(c): # the first install has nothing running to wait for
alert(c, f"release {m['seq']}: the fly never became ready for a restart (orders/claims/settlement in flight); will retry")
return "busy"
if infra: # approved: the release's compose file becomes the running one
shutil.copy2(live, backup)
shutil.copy2(d / "deploy" / "docker-compose.vault.yml", live)
prev = st.get("image")
env = {"FLY_RELEASES": str(root / "releases")}
compose(c, "up", "-d", check=False, env={**env, "FLY_RELEASE": str(m["seq"])})
if healthy(c):
return "ok"
alert(c, f"release {m['seq']} did not become healthy; rolling back to {prev}")
if infra and backup.exists():
shutil.copy2(backup, live)
if prev:
compose(c, "up", "-d", check=False, env={**env, "FLY_RELEASE": str(prev)})
return "failed"
def _when(ts: float) -> str:
return time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(ts))
def apply(c: dict, st: dict, m: dict, d: Path, raw: bytes, hf_sha: str, approved: bool = False, who: str = "?",
now: float | None = None) -> None:
now = time.time() if now is None else now
msha = hashlib.sha256(raw).hexdigest()
old = installed_manifest(c, st)
if old is None: # the first release is pinned by hand at bootstrap (no TOFU)
pin = c.get("expect_manifest_sha256")
if not pin:
raise SystemExit("no release installed and update.json pins none (expect_manifest_sha256): refusing to trust the first one seen")
if msha != pin:
raise SystemExit(f"first release's manifest {msha[:12]} is not the pinned {str(pin)[:12]}")
else:
if m["seq"] <= old["seq"]:
raise SystemExit(f"release seq {m['seq']} is not newer than installed {old['seq']} (rollback refused)")
pend = st.get("pending") or {}
chain = {st.get("manifest_sha256"), pend.get("manifest_sha256"), *(st.get("vetoed") or [])} - {None}
if m.get("prev_sha256") not in chain:
raise SystemExit("release does not chain from the installed one (prev_sha256 mismatch)")
classes = changed_classes(old, m)
if old is not None and classes & {"code", "infra"}:
pend = st.get("pending") or {}
if pend.get("manifest_sha256") != msha: # newly seen: the delay starts now (a replaced pending one restarts it)
pend = {"seq": m["seq"], "hf_sha": hf_sha, "manifest_sha256": msha, "classes": sorted(classes), "signer": who,
"first_seen": now, "due_at": now + float(c.get("code_delay_s", 86400)), "approved": False}
st["pending"] = pend
what = ("changes Docker/compose/entrypoint files: review it, then `sudo python3 /usr/local/lib/fly/fly_update.py approve "
f"{m['seq']}`" if "infra" in classes else
f"changes code: it applies at {_when(pend['due_at'])} unless you run `sudo python3 /usr/local/lib/fly/fly_update.py veto "
f"{m['seq']}` (`approve {m['seq']}` applies it now)")
alert(c, f"release {m['seq']} signed by {who} {what}")
if approved:
pend["approved"] = True
save_state(c, st)
if "infra" in classes and not pend["approved"]:
return
if not (pend["approved"] or now >= pend["due_at"]):
return
if classes & {"code", "infra"} or st.get("image") is None:
res = deploy_code(c, st, m, d, "infra" in classes)
if res == "busy":
return
if res == "failed":
raise SystemExit("the new image did not come up healthy (rolled back)")
st["image"] = m["seq"]
elif classes & {"model", "seed"}:
r = compose(c, "exec", "-T", "fly", "fly-trader", "apply-release", f"/releases/{m['seq']}", check=False)
if r.returncode != 0:
raise SystemExit(f"apply-release failed: {(r.stderr or r.stdout)[-300:]}")
if not healthy(c, 300):
if old is not None:
compose(c, "exec", "-T", "fly", "fly-trader", "apply-release", f"/releases/{old['seq']}", check=False)
raise SystemExit("unhealthy after the model release; the previous release's models were re-applied")
st.update({"seq": m["seq"], "manifest_sha256": msha, "hf_sha": hf_sha, "pending": None})
save_state(c, st)
alert(c, f"release {m['seq']} applied ({', '.join(sorted(classes))}), signed by {who}")
def run(approve_seq: int | None = None, now: float | None = None) -> None:
c = conf()
now = time.time() if now is None else now
Path(c["root"], "releases").mkdir(parents=True, exist_ok=True)
st = load_state(c)
pend = st.get("pending") or {}
if approve_seq is not None:
if pend.get("seq") != approve_seq:
raise SystemExit(f"no pending release {approve_seq}")
sha = pend["hf_sha"]
else:
head = json.loads(http(f"https://huggingface.co/api/models/{c['repo']}"))["sha"]
if head not in (st.get("hf_sha"), st.get("hf_sha_rejected"), pend.get("hf_sha")):
sha = head # a new release
elif pend and (pend.get("approved") or now >= pend.get("due_at", float("inf"))):
sha = pend["hf_sha"] # a held release whose time came (or a retry after 'busy')
else:
return # installed, rejected (alerted once), or still held
try:
m, d, raw, who = fetch(c, sha)
apply(c, st, m, d, raw, sha, approved=approve_seq is not None, who=who, now=now)
except SystemExit as e:
st = load_state(c)
if (st.get("pending") or {}).get("hf_sha") == sha:
st["pending"] = None
st["hf_sha_rejected"] = sha; save_state(c, st)
alert(c, f"release at {sha[:10]} rejected: {e}")
raise
def veto(seq: int) -> None:
c = conf(); st = load_state(c); pend = st.get("pending") or {}
if pend.get("seq") != seq:
raise SystemExit(f"no pending release {seq}")
st["vetoed"] = [*(st.get("vetoed") or []), pend["manifest_sha256"]][-20:]
st["hf_sha_rejected"] = pend["hf_sha"]; st["pending"] = None
save_state(c, st)
alert(c, f"release {seq} VETOED: it will never apply. If you did not publish it, your release key is compromised: "
"remove it from /etc/fly/allowed_signers and rotate.")
def main(argv: list[str]) -> None:
cmd = argv[1] if len(argv) > 1 else "run"
if cmd == "run":
run()
elif cmd == "approve":
run(int(argv[2]))
elif cmd == "veto":
veto(int(argv[2]))
elif cmd == "restart":
c = conf()
if load_state(c).get("image") is None:
raise SystemExit("nothing installed yet")
r = compose(c, "up", "-d", "--force-recreate", check=False, env={"FLY_RELEASES": str(Path(c["root"]) / "releases")})
print((r.stdout or "") + (r.stderr or ""))
elif cmd == "status":
print(json.dumps(load_state(conf()), indent=1))
elif cmd == "verify": # used by tools/publish_release.py on the Mac before uploading
d = Path(argv[2]); c = {"allowed_signers": argv[3], "principals": argv[4:] or ["bryce"], "namespace": "fly-trader-release"}
raw = (d / "release.json").read_bytes()
verify_signature(c, raw, (d / "release.json.sig").read_bytes())
m = json.loads(raw); check_manifest(m); verify_dir(d, m)
print(f"release {m['seq']}: signature and {len(m['files'])} files verify")
else:
raise SystemExit(__doc__)
if __name__ == "__main__":
main(sys.argv)
|