fly-trader / tests /test_release.py
bryceweiner's picture
release 1 (7ab2355a0c) (part 2)
6ca93dc verified
Raw History Blame Contribute Delete
12.7 kB
"""Signed releases: what the server's updater (deploy/fly_update.py) accepts and refuses."""
import hashlib
import json
import subprocess
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parents[1] / "deploy"))
import fly_update as fu # noqa: E402
@pytest.fixture()
def signer(tmp_path):
key = tmp_path / "k"
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], check=True)
signers = tmp_path / "allowed_signers"
signers.write_text('bryce namespaces="fly-trader-release" ' + (tmp_path / "k.pub").read_text())
return key, {"allowed_signers": str(signers), "principals": ["bryce", "bryce-recovery"], "namespace": "fly-trader-release"}
def make_release(d: Path, key: Path, files: dict, seq=1, prev=None) -> bytes:
d.mkdir(parents=True, exist_ok=True)
entries = []
for rel, data in files.items():
p = d / rel; p.parent.mkdir(parents=True, exist_ok=True); p.write_bytes(data)
entries.append({"path": rel, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data), "class": fu.classify(rel)})
raw = json.dumps({"schema": 1, "seq": seq, "prev_sha256": prev, "files": entries}, sort_keys=True).encode()
(d / "release.json").write_bytes(raw)
subprocess.run(["ssh-keygen", "-Y", "sign", "-f", str(key), "-n", "fly-trader-release", str(d / "release.json")], check=True, capture_output=True)
return raw
FILES = {"fly_trader/x.py": b"print(1)\n", "models/policies/f.pt": b"\x00" * 64, "Dockerfile": b"FROM x\n", "seed/release_seed.json": b"{}"}
def test_good_release_verifies(signer, tmp_path):
key, c = signer
raw = make_release(tmp_path / "r", key, FILES)
fu.verify_signature(c, raw, (tmp_path / "r" / "release.json.sig").read_bytes())
m = json.loads(raw); fu.check_manifest(m); fu.verify_dir(tmp_path / "r", m)
def test_tampered_extra_and_foreign_key_refused(signer, tmp_path):
key, c = signer
raw = make_release(tmp_path / "r", key, FILES)
m = json.loads(raw)
(tmp_path / "r" / "fly_trader" / "x.py").write_bytes(b"import os; os.system('evil')\n")
with pytest.raises(SystemExit):
fu.verify_dir(tmp_path / "r", m)
(tmp_path / "r" / "fly_trader" / "x.py").write_bytes(FILES["fly_trader/x.py"])
(tmp_path / "r" / "sitecustomize.py").write_text("evil")
with pytest.raises(SystemExit):
fu.verify_dir(tmp_path / "r", m)
with pytest.raises(SystemExit): # manifest edited after signing
fu.verify_signature(c, raw.replace(b'"seq": 1', b'"seq": 9'), (tmp_path / "r" / "release.json.sig").read_bytes())
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(tmp_path / "other")], check=True)
raw2 = make_release(tmp_path / "r2", tmp_path / "other", FILES)
with pytest.raises(SystemExit):
fu.verify_signature(c, raw2, (tmp_path / "r2" / "release.json.sig").read_bytes())
def test_bad_paths_and_classes(signer):
for p in ("../etc/passwd", "/abs", "a/../b", ".git/config"):
with pytest.raises(SystemExit):
fu.check_manifest({"schema": 1, "seq": 1, "files": [{"path": p, "sha256": "0" * 64, "size": 1, "class": "code"}]})
with pytest.raises(SystemExit): # infra disguised as code
fu.check_manifest({"schema": 1, "seq": 1, "files": [{"path": "docker/entrypoint.sh", "sha256": "0" * 64, "size": 1, "class": "code"}]})
def test_changed_classes_decide_the_path():
old = {"files": [{"path": "models/a.pt", "sha256": "1"}, {"path": "fly_trader/x.py", "sha256": "2"}, {"path": "Dockerfile", "sha256": "3"}]}
new = {"files": [{"path": "models/a.pt", "sha256": "9"}, {"path": "fly_trader/x.py", "sha256": "2"}, {"path": "Dockerfile", "sha256": "3"}]}
assert fu.changed_classes(old, new) == {"model"}
new["files"][2]["sha256"] = "4"
assert fu.changed_classes(old, new) == {"model", "infra"}
def test_rollback_and_fork_refused(tmp_path, monkeypatch):
c = {"root": str(tmp_path), "project": "t"}
(tmp_path / "releases" / "5").mkdir(parents=True)
old = {"schema": 1, "seq": 5, "files": []}
raw_old = json.dumps(old).encode(); (tmp_path / "releases" / "5" / "release.json").write_bytes(raw_old)
st = {"seq": 5, "manifest_sha256": hashlib.sha256(raw_old).hexdigest(), "image": 5}
with pytest.raises(SystemExit, match="not newer"):
fu.apply(c, st, {"schema": 1, "seq": 4, "prev_sha256": st["manifest_sha256"], "files": []}, tmp_path, b"x", "sha")
with pytest.raises(SystemExit, match="chain"):
fu.apply(c, st, {"schema": 1, "seq": 6, "prev_sha256": "f" * 64, "files": []}, tmp_path, b"x", "sha")
def test_infra_change_waits_for_approval(tmp_path, monkeypatch):
c = {"root": str(tmp_path), "project": "t"}
(tmp_path / "releases" / "1").mkdir(parents=True)
old = {"schema": 1, "seq": 1, "files": [{"path": "Dockerfile", "sha256": "a"}]}
raw_old = json.dumps(old).encode(); (tmp_path / "releases" / "1" / "release.json").write_bytes(raw_old)
st = {"seq": 1, "manifest_sha256": hashlib.sha256(raw_old).hexdigest(), "image": 1}
monkeypatch.setattr(fu, "deploy_code", lambda *a, **k: pytest.fail("must not deploy infra without approval"))
new = {"schema": 1, "seq": 2, "prev_sha256": st["manifest_sha256"], "files": [{"path": "Dockerfile", "sha256": "b"}]}
fu.apply(c, st, new, tmp_path, b"raw", "hf2")
assert json.loads((tmp_path / "state.json").read_text())["pending"]["seq"] == 2
def installed(tmp_path, seq=1, files=None, **conf):
"""An install at ``seq`` whose manifest lists ``files``; returns (conf, state, manifest sha)."""
c = {"root": str(tmp_path), "project": "t", **conf}
(tmp_path / "releases" / str(seq)).mkdir(parents=True, exist_ok=True)
raw = json.dumps({"schema": 1, "seq": seq, "files": files or [{"path": "fly_trader/x.py", "sha256": "a"}]}).encode()
(tmp_path / "releases" / str(seq) / "release.json").write_bytes(raw)
sha = hashlib.sha256(raw).hexdigest()
return c, {"seq": seq, "manifest_sha256": sha, "image": seq}, sha
def test_recovery_principal_verifies_and_namespace_is_enforced(signer, tmp_path):
key, c = signer
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(tmp_path / "rec")], check=True)
with open(c["allowed_signers"], "a") as f:
f.write('bryce-recovery namespaces="fly-trader-release" ' + (tmp_path / "rec.pub").read_text())
raw = make_release(tmp_path / "r", tmp_path / "rec", FILES)
assert fu.verify_signature(c, raw, (tmp_path / "r" / "release.json.sig").read_bytes()) == "bryce-recovery"
(tmp_path / "m").write_bytes(raw) # same key, another namespace: refused
subprocess.run(["ssh-keygen", "-Y", "sign", "-f", str(key), "-n", "file", str(tmp_path / "m")], check=True, capture_output=True)
with pytest.raises(SystemExit):
fu.verify_signature({**c, "namespace": "file"}, raw, (tmp_path / "m.sig").read_bytes())
def test_models_and_seed_cannot_carry_code():
for p in ("models/selectors/s.joblib", "models/policies/p.pkl", "models/x.py", "seed/evil.py"):
cls = fu.classify(p)
with pytest.raises(SystemExit, match="not allowed"):
fu.check_manifest({"schema": 1, "seq": 1, "files": [{"path": p, "sha256": "0" * 64, "size": 1, "class": cls}]})
fu.check_manifest({"schema": 1, "seq": 1, "files": [{"path": "models/selectors/s.skops", "sha256": "0" * 64, "size": 1, "class": "model"},
{"path": "models/wallet_skill/TABLE", "sha256": "0" * 64, "size": 1, "class": "model"}]})
def test_first_release_must_be_pinned(tmp_path, monkeypatch):
c = {"root": str(tmp_path), "project": "t"}
monkeypatch.setattr(fu, "deploy_code", lambda *a, **k: "ok")
m = {"schema": 1, "seq": 1, "files": []}
with pytest.raises(SystemExit, match="pins none"):
fu.apply(c, {"seq": 0}, m, tmp_path, b"raw", "hf1")
with pytest.raises(SystemExit, match="not the pinned"):
fu.apply({**c, "expect_manifest_sha256": "0" * 64}, {"seq": 0}, m, tmp_path, b"raw", "hf1")
st = {"seq": 0}
fu.apply({**c, "expect_manifest_sha256": hashlib.sha256(b"raw").hexdigest()}, st, m, tmp_path, b"raw", "hf1")
assert st["seq"] == 1 and st["image"] == 1
def test_code_release_is_held_24h_then_applies(tmp_path, monkeypatch):
c, st, prev = installed(tmp_path)
deployed = []
monkeypatch.setattr(fu, "deploy_code", lambda c_, st_, m, d, infra: deployed.append(m["seq"]) or "ok")
new = {"schema": 1, "seq": 2, "prev_sha256": prev, "files": [{"path": "fly_trader/x.py", "sha256": "b"}]}
fu.apply(c, st, new, tmp_path, b"raw2", "hf2", now=1000.0)
assert deployed == [] and st["pending"]["due_at"] == 1000.0 + 86400
fu.apply(c, st, new, tmp_path, b"raw2", "hf2", now=1000.0 + 86399)
assert deployed == []
fu.apply(c, st, new, tmp_path, b"raw2", "hf2", now=1000.0 + 86400)
assert deployed == [2] and st["seq"] == 2 and st["pending"] is None
def test_approve_applies_code_at_once_but_infra_only_with_it(tmp_path, monkeypatch):
c, st, prev = installed(tmp_path, files=[{"path": "Dockerfile", "sha256": "a"}])
deployed = []
monkeypatch.setattr(fu, "deploy_code", lambda c_, st_, m, d, infra: deployed.append((m["seq"], infra)) or "ok")
new = {"schema": 1, "seq": 2, "prev_sha256": prev, "files": [{"path": "Dockerfile", "sha256": "b"}]}
fu.apply(c, st, new, tmp_path, b"raw2", "hf2", now=0.0)
fu.apply(c, st, new, tmp_path, b"raw2", "hf2", now=10 * 86400.0) # long past the delay: infra still waits
assert deployed == []
fu.apply(c, st, new, tmp_path, b"raw2", "hf2", approved=True, now=10 * 86400.0)
assert deployed == [(2, True)]
def test_newer_release_restarts_the_delay(tmp_path, monkeypatch):
c, st, prev = installed(tmp_path)
monkeypatch.setattr(fu, "deploy_code", lambda *a, **k: pytest.fail("held"))
r2 = {"schema": 1, "seq": 2, "prev_sha256": prev, "files": [{"path": "fly_trader/x.py", "sha256": "b"}]}
fu.apply(c, st, r2, tmp_path, b"raw2", "hf2", now=0.0)
r3 = {"schema": 1, "seq": 3, "prev_sha256": hashlib.sha256(b"raw2").hexdigest(), "files": [{"path": "fly_trader/x.py", "sha256": "c"}]}
fu.apply(c, st, r3, tmp_path, b"raw3", "hf3", now=86000.0) # chains from the held one
assert st["pending"]["seq"] == 3 and st["pending"]["due_at"] == 86000.0 + 86400
def test_veto(tmp_path, monkeypatch):
c, st, prev = installed(tmp_path)
monkeypatch.setattr(fu, "conf", lambda: c)
monkeypatch.setattr(fu, "deploy_code", lambda *a, **k: pytest.fail("vetoed"))
r2 = {"schema": 1, "seq": 2, "prev_sha256": prev, "files": [{"path": "fly_trader/x.py", "sha256": "evil"}]}
fu.apply(c, st, r2, tmp_path, b"raw2", "hf2", now=0.0)
fu.veto(2)
st = fu.load_state(c)
assert st["pending"] is None and st["hf_sha_rejected"] == "hf2" and hashlib.sha256(b"raw2").hexdigest() in st["vetoed"]
with pytest.raises(SystemExit):
fu.veto(2)
# the next real release (built on top of the vetoed one on HF) is still accepted -- and held again
r3 = {"schema": 1, "seq": 3, "prev_sha256": hashlib.sha256(b"raw2").hexdigest(), "files": [{"path": "fly_trader/x.py", "sha256": "good"}]}
fu.apply(c, st, r3, tmp_path, b"raw3", "hf3", now=1.0)
assert st["pending"]["seq"] == 3
def test_failed_deploy_is_rejected_not_retried(tmp_path, monkeypatch):
c, st, prev = installed(tmp_path)
monkeypatch.setattr(fu, "deploy_code", lambda *a, **k: "failed")
r2 = {"schema": 1, "seq": 2, "prev_sha256": prev, "files": [{"path": "fly_trader/x.py", "sha256": "b"}]}
with pytest.raises(SystemExit, match="healthy"):
fu.apply(c, st, r2, tmp_path, b"raw2", "hf2", approved=True, now=0.0)
def test_run_holds_then_applies_when_due(tmp_path, monkeypatch):
c, st, prev = installed(tmp_path, repo="x/y")
fu.save_state(c, st)
monkeypatch.setattr(fu, "conf", lambda: c)
monkeypatch.setattr(fu, "http", lambda url, *a, **k: json.dumps({"sha": "hf2"}).encode())
r2 = {"schema": 1, "seq": 2, "prev_sha256": prev, "files": [{"path": "fly_trader/x.py", "sha256": "b"}]}
fetched = []
monkeypatch.setattr(fu, "fetch", lambda c_, sha: fetched.append(sha) or (r2, tmp_path, b"raw2", "bryce"))
deployed = []
monkeypatch.setattr(fu, "deploy_code", lambda *a, **k: deployed.append(1) or "ok")
fu.run(now=0.0)
fu.run(now=3600.0) # held: not even fetched again
assert fetched == ["hf2"] and deployed == []
fu.run(now=86400.0)
assert deployed == [1] and fu.load_state(c)["seq"] == 2