File size: 12,671 Bytes
6570567 6ca93dc 6570567 6ca93dc | 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 | """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
|