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)