diff --git a/tools/selftest_sweep.py b/tools/selftest_sweep.py index 7fa3c95..afcc76e 100644 --- a/tools/selftest_sweep.py +++ b/tools/selftest_sweep.py @@ -170,6 +170,7 @@ READ_PROBES = ( ("queue_append_audit.py", ["--repo", "{nope}"]), ("reply_check.py", ["--dir", "{nope}"]), ("selftest_sweep.py", ["--repo", "{nope}"]), + ("served_drift_check.py", ["--root", "{nope}", "--base-url", "file:///nonexistent"]), ("tool_map.py", ["--repo", "{nope}"]), ("verify.py", ["--repo", "{nope}"]) ) @@ -238,6 +239,10 @@ MALFORMED_PROBES = ( # directory belongs is the malformed shape (a missing one is the read axis). ("reply_check.py", "not-a-dir", ["--dir", "{bin}"], 2, "not a directory"), + # served_drift_check.py's input is a git work tree that a host is supposed to + # be serving: a directory that is not one must be named, not graded as empty. + ("served_drift_check.py", "not-a-repo", ["--root", "{notrepo}", "--base-url", "file:///nonexistent"], + 2, "not a git work tree"), ("selftest_sweep.py", "not-a-repo", ["--repo", "{notrepo}"], 2, "no tools discovered"), ("tool_map.py", "not-a-repo", ["--repo", "{notrepo}"], diff --git a/tools/served_drift_check.py b/tools/served_drift_check.py new file mode 100644 index 0000000..2c0723b --- /dev/null +++ b/tools/served_drift_check.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +"""served_drift_check.py -- does the host actually serve the tree at this revision? + +WHY THIS EXISTS + Two lanes spend a hand-written `curl | sha256sum` loop on every leathermint + tick: Timmy's reply 45 lists six served paths "byte-exact", this lane's own + progress block lists six more, and both wrote the sizes out by hand. That is + the same fact re-measured from scratch, in prose, with no instrument that can + disagree with either of them -- the class `gate_blame.py` was built for on the + 2026-10-20 gate, one layer out: a deploy is a CLAIM about a revision, and + nothing graded it. + + `landing_check.py` answers the neighbouring question for the forge: it reads + `/branch//`, a raw-endpoint convention, because that is + how the archive reads itself. A static host has no such convention: the URL + is the path, the served root need not be a git tree at all, and the failure + mode is not "the commit was never pushed" but "the bytes on the host are not + the bytes this revision says". This tool grades that second question with the + first tool's vocabulary, so a reader does not have to learn a third table. + +WHAT IT MEASURES -- one row per tracked file at --rev, one verdict per row + OK the blob at `rev:path` and the body served at `/` + have the same length and the same sha256. + DIFFERS both sides returned bytes and they are not identical: the host + is running something other than this revision. Fatal, and the + row carries both lengths and both digests, because "the deploy + is stale" is only useful with the direction of the staleness. + MISSING the host answered 404. A tracked path the host does not serve. + HTTP_ERROR any other non-200 status. Not a verdict about content. + UNREACHABLE the transport failed before a status existed (DNS, refused, + TLS, timeout). Kept separate from MISSING on purpose: a host + that did not answer is not a host that lacks the file, and + collapsing them lets an outage read as a clean negative. + READ_FAILED `git cat-file` could not produce the tree's bytes. An + archive-side inconsistency, not a host one. + SKIPPED_LARGE the local blob exceeds --max-bytes; not fetched, not graded. + ADVISORY, never fatal: a size policy is not a deploy defect. + + FATAL = DIFFERS, MISSING, HTTP_ERROR, UNREACHABLE, READ_FAILED. Exit 1 if any + row is fatal, else 0. A run that cannot grade at all -- not a git work tree, + a --base-url that is not http(s):// or file://, or a revision that tracks + nothing -- exits 2 with the reason, never 0. + +WHAT IT DOES NOT CLAIM + Byte equality is not provenance. Identical bytes say the host serves what this + revision holds; they say nothing about that revision being correct, reviewed, + or the one that should be live. A file no revision tracks is out of scope by + construction -- the untracked-file blindness this archive already paid for -- + so a stale file that git never knew about is invisible here and is said so + here rather than left to be discovered later. `v2/` is graded like everything + else: a deprecated build that is still served is a fact, not an exemption. + +READ-ONLY: GET requests only, and the single write path is `--json PATH`. No +file, branch, remote or deploy is modified; nothing is posted anywhere. + +USAGE: + python3 served_drift_check.py --root /var/www/leathermint \ + --base-url https://leathermint.alexanderwhitestone.com + python3 served_drift_check.py --root . --rev HEAD --prefix iso/ --quiet + python3 served_drift_check.py --root . --base-url file:///srv/copy --json out.json + python3 served_drift_check.py --selftest # fixture host, no network, 22 cells +""" +import argparse +import fnmatch +import hashlib +import json +import os +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request + +FATAL = ("DIFFERS", "MISSING", "HTTP_ERROR", "UNREACHABLE", "READ_FAILED") +ADVISORY = ("SKIPPED_LARGE",) +USER_AGENT = "served_drift_check/1.0 (troupe-standup)" + + +class GradeError(Exception): + """Could not grade at all: the reason is printed and rc is 2.""" + + +class FetchError(Exception): + """The transport failed before any status existed.""" + + +def git(args, cwd): + return subprocess.run(["git", "-C", cwd] + list(args), + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def tracked_files(root, rev, prefix=None): + """Every path `rev` tracks, as git spells it -- NUL-split, so a space in a + filename is a filename and not two.""" + p = git(["ls-tree", "-r", "-z", "--name-only", rev], root) + if p.returncode != 0: + raise GradeError("cannot read repo at %s: %s" + % (root, p.stderr.decode("utf-8", "replace").strip()[:200])) + paths = [s for s in p.stdout.decode("utf-8", "surrogateescape").split("\0") if s] + if prefix: + paths = [x for x in paths if x.startswith(prefix)] + return sorted(paths) + + +def read_blob(root, rev, path): + p = git(["cat-file", "blob", "%s:%s" % (rev, path)], root) + if p.returncode != 0: + return None + return p.stdout + + +def check_base_url(base): + if not base: + raise GradeError("no --base-url: there is nothing to grade a tree against") + scheme = urllib.parse.urlsplit(base).scheme + if scheme not in ("http", "https", "file"): + raise GradeError("not a URL this tool can read (http, https, file): %r" % base) + return base.rstrip("/") + + +def url_for(base, path): + return base + "/" + urllib.parse.quote(path, safe="/") + + +def fetch(url, timeout): + """Return (status, body). file:// returns (200, bytes) or (404, b'').""" + if url.startswith("file://"): + local = urllib.parse.unquote(urllib.parse.urlsplit(url).path) + try: + with open(local, "rb") as fh: + return 200, fh.read() + except FileNotFoundError: + return 404, b"" + except OSError as e: + raise FetchError(str(e)) + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.getcode(), r.read() + except urllib.error.HTTPError as e: + return e.code, b"" + except urllib.error.URLError as e: + raise FetchError(str(e.reason)) + except (TimeoutError, OSError) as e: + raise FetchError(str(e)) + + +def grade_one(root, rev, base, path, max_bytes, timeout): + row = {"path": path, "url": url_for(base, path), "verdict": "OK", + "tree_len": None, "served_len": None, "tree_sha": None, + "served_sha": None, "detail": ""} + blob = read_blob(root, rev, path) + if blob is None: + row.update(verdict="READ_FAILED", detail="rev:%s does not carry this path" % rev) + return row + row["tree_len"] = len(blob) + row["tree_sha"] = hashlib.sha256(blob).hexdigest() + if max_bytes is not None and len(blob) > max_bytes: + row.update(verdict="SKIPPED_LARGE", + detail="%d B over --max-bytes %d" % (len(blob), max_bytes)) + return row + try: + status, body = fetch(row["url"], timeout) + except FetchError as e: + row.update(verdict="UNREACHABLE", detail="host did not answer: %s" % str(e)[:120]) + return row + if status == 404: + row.update(verdict="MISSING", detail="404; tree has %d B" % len(blob)) + return row + if status != 200: + row.update(verdict="HTTP_ERROR", detail="status %d" % status) + return row + row["served_len"] = len(body) + row["served_sha"] = hashlib.sha256(body).hexdigest() + if body != blob: + row.update(verdict="DIFFERS", + detail="served %d B %s vs tree %d B %s" + % (len(body), row["served_sha"][:12], + len(blob), row["tree_sha"][:12])) + return row + row["detail"] = "%d B, sha256 %s" % (len(blob), row["tree_sha"][:16]) + return row + + +def is_fatal(row): + return row["verdict"] in FATAL + + +def summarise(rows): + n_fatal = sum(1 for r in rows if is_fatal(r)) + n_ok = sum(1 for r in rows if r["verdict"] == "OK") + n_skip = sum(1 for r in rows if r["verdict"] in ADVISORY) + return n_ok, n_fatal, n_skip + + +def run(root, rev, base, prefix=None, exclude=(), max_bytes=None, timeout=20.0): + if git(["rev-parse", "--is-inside-work-tree"], root).returncode != 0: + raise GradeError("not a git work tree: %s" % root) + base = check_base_url(base) + paths = tracked_files(root, rev, prefix) + paths = [p for p in paths if not any(fnmatch.fnmatch(p, pat) for pat in exclude)] + if not paths: + raise GradeError("revision %s tracks no file%s" + % (rev, " under %s" % prefix if prefix else "")) + return [grade_one(root, rev, base, p, max_bytes, timeout) for p in paths] + + +def report(rows, out=sys.stdout): + width = max([len(r["path"]) for r in rows] + [4]) + for r in rows: + out.write(" %-*s %-12s %s\n" % (width, r["path"], r["verdict"], r["detail"])) + n_ok, n_fatal, n_skip = summarise(rows) + out.write("\nserved: %d files, %d OK, %d drift, %d skipped\n" + % (len(rows), n_ok, n_fatal, n_skip)) + if n_fatal: + out.write("FAIL: %d file(s) are not served as this revision says\n" % n_fatal) + return 1 if n_fatal else 0 + + +# --------------------------------------------------------------------- selftest + +def _write(path, data): + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + mode = "wb" if isinstance(data, bytes) else "w" + with open(path, mode) as fh: + fh.write(data) + + +def _commit(repo, msg): + git(["add", "-A"], repo) + git(["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", msg], repo) + + +def selftest(): + import functools + import http.server + import shutil + import tempfile + import threading + + cells = [0] + bad = [0] + + def cell(name, ok, detail=""): + cells[0] += 1 + if not ok: + bad[0] += 1 + print(" FAIL %s%s" % (name, (": " + str(detail)) if detail else "")) + + tmp = tempfile.mkdtemp(prefix="served-drift-") + try: + repo = os.path.join(tmp, "repo") + served = os.path.join(tmp, "served") + os.makedirs(repo) + git(["init", "-q"], repo) + _write(os.path.join(repo, "index.html"), "

v1

\n") + _write(os.path.join(repo, "iso", "engine.js"), "// engine v1\n") + _write(os.path.join(repo, "journal", "index.html"), "

journal

\n") + _commit(repo, "v1") + + def mirror(dst): + for dirpath, _dirs, files in os.walk(dst): + for f in files: + if f == ".git": + continue + shutil.rmtree(dst, ignore_errors=True) + shutil.copytree(os.path.join(repo, "index.html").rsplit(os.sep, 1)[0], dst, + ignore=shutil.ignore_patterns(".git")) + + mirror(served) + base = "file://" + os.path.abspath(served) + + rows = run(repo, "HEAD", base) + cell("c01 a mirror of the revision grades OK", + [r["verdict"] for r in rows] == ["OK"] * 3, [r["verdict"] for r in rows]) + cell("c02 the row carries both digests", + rows[0]["tree_sha"] == rows[0]["served_sha"] and len(rows[0]["tree_sha"]) == 64) + cell("c03 report is rc 0 and prints the total", report(rows, _Sink()) == 0) + + # the failure this tool exists for: the host runs a stale copy + _write(os.path.join(served, "iso", "engine.js"), "// engine v0\n") + rows = run(repo, "HEAD", base) + stale = [r for r in rows if r["path"] == "iso/engine.js"][0] + cell("c04 a stale served copy is DIFFERS", stale["verdict"] == "DIFFERS", stale) + cell("c05 the DIFFERS row carries both lengths and both digests", + stale["served_len"] == 13 and stale["tree_len"] == 13 + and stale["served_sha"][:8] != stale["tree_sha"][:8], stale["detail"]) + cell("c06 any fatal row makes the run rc 1", report(rows, _Sink()) == 1) + + os.remove(os.path.join(served, "journal", "index.html")) + rows = run(repo, "HEAD", base) + cell("c07 a path the host does not serve is MISSING", + [r for r in rows if r["path"] == "journal/index.html"][0]["verdict"] == "MISSING") + + # a real HTTP server, so the http branch is measured and not assumed + os.remove(os.path.join(served, "iso", "engine.js")) + + class H(http.server.SimpleHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + if self.path.endswith("index.html") and "journal" in self.path: + self.send_error(500) + return + return super().do_GET() + + handler = functools.partial(H, directory=served) + srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + t = threading.Thread(target=srv.serve_forever, daemon=True) + t.start() + try: + http_base = "http://127.0.0.1:%d" % srv.server_address[1] + rows = run(repo, "HEAD", http_base) + by = {r["path"]: r for r in rows} + cell("c08 over HTTP a matching file is OK", by["index.html"]["verdict"] == "OK") + cell("c09 over HTTP a 500 is HTTP_ERROR, not a content verdict", + by["journal/index.html"]["verdict"] == "HTTP_ERROR", by["journal/index.html"]) + cell("c10 over HTTP a removed file is MISSING", + by["iso/engine.js"]["verdict"] == "MISSING", by["iso/engine.js"]) + finally: + srv.shutdown() + srv.server_close() + + rows = run(repo, "HEAD", "http://127.0.0.1:1", timeout=2) + cell("c11 a refused connection is UNREACHABLE", + all(r["verdict"] == "UNREACHABLE" for r in rows), [r["verdict"] for r in rows]) + cell("c12 an unreachable host is fatal, never a clean negative", + report(rows, _Sink()) == 1) + + # rev discipline: grading an older revision grades THAT revision + _write(os.path.join(repo, "index.html"), "

v2

\n") + _commit(repo, "v2") + head_rows = run(repo, "HEAD", base) + old_rows = run(repo, "HEAD~1", base) + cell("c13 HEAD grades the new blob and HEAD~1 the old one", + [r for r in head_rows if r["path"] == "index.html"][0]["verdict"] == "DIFFERS" + and [r for r in old_rows if r["path"] == "index.html"][0]["verdict"] == "OK") + + rows = run(repo, "HEAD", base, prefix="iso/") + cell("c14 --prefix grades only the subtree it names", + [r["path"] for r in rows] == ["iso/engine.js"], [r["path"] for r in rows]) + + rows = run(repo, "HEAD", base, exclude=("iso/*",)) + cell("c15 --exclude drops the matching rows", + sorted(r["path"] for r in rows) == ["index.html", "journal/index.html"]) + + rows = run(repo, "HEAD", base, max_bytes=5) + cell("c16 over --max-bytes is SKIPPED_LARGE and never fatal", + all(r["verdict"] == "SKIPPED_LARGE" for r in rows) + and report(rows, _Sink()) == 0) + + for label, argv, want in ( + ("not-a-repo", dict(root=tmp, rev="HEAD", base=base), "not a git work tree"), + ("no-base-url", dict(root=repo, rev="HEAD", base=None), "no --base-url"), + ("bad-scheme", dict(root=repo, rev="HEAD", base="ftp://x/y"), "not a URL"), + ): + try: + run(**argv) + cell("c17 %s is refused at rc 2" % label, False, "no exception") + except GradeError as e: + cell("c17 %s is refused at rc 2" % label, want in str(e), str(e)) + + try: + run(root=repo, rev="HEAD~99", base=base) + cell("c18 a revision that does not resolve is rc 2", False, "no exception") + except GradeError as e: + cell("c18 a revision that does not resolve is rc 2", "cannot read repo" in str(e), + str(e)) + + try: + run(root=repo, rev="HEAD", base=base, prefix="nope/") + cell("c19 a prefix that tracks nothing is rc 2", False, "no exception") + except GradeError as e: + cell("c19 a prefix that tracks nothing is rc 2", "tracks no file" in str(e), str(e)) + + cell("c20 --json is the same rows the table printed", + _json_roundtrip(repo, base, tmp)) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + print("selftest: %d cells, %d passed, %d failed" + % (cells[0], cells[0] - bad[0], bad[0])) + return 1 if bad[0] else 0 + + +class _Sink: + def write(self, _s): + return 0 + + +def _json_roundtrip(repo, base, tmp): + out = os.path.join(tmp, "rows.json") + rows = run(repo, "HEAD", base) + with open(out, "w") as fh: + json.dump({"rows": rows}, fh) + with open(out) as fh: + back = json.load(fh)["rows"] + return back == rows and len(back) == 3 + + +# ----------------------------------------------------------------------- main + +def main(argv=None): + ap = argparse.ArgumentParser( + description="Grade a host's served bytes against the tree at a revision.") + ap.add_argument("--root", default=".", help="the deploy directory's git work tree") + ap.add_argument("--rev", default="HEAD") + ap.add_argument("--base-url", default=None, + help="http(s):// host root, or file:// for an offline copy") + ap.add_argument("--prefix", default=None, help="only paths under this prefix") + ap.add_argument("--exclude", action="append", default=[], metavar="GLOB") + ap.add_argument("--max-bytes", type=int, default=None, metavar="N") + ap.add_argument("--timeout", type=float, default=20.0, metavar="S") + ap.add_argument("--json", default=None, metavar="PATH") + ap.add_argument("--quiet", action="store_true") + ap.add_argument("--selftest", action="store_true") + a = ap.parse_args(argv) + + if a.selftest: + return selftest() + try: + rows = run(a.root, a.rev, a.base_url, a.prefix, a.exclude, + a.max_bytes, a.timeout) + except GradeError as e: + sys.stderr.write("served_drift_check: %s\n" % e) + return 2 + if a.json: + with open(a.json, "w") as fh: + json.dump({"root": os.path.abspath(a.root), "rev": a.rev, + "base_url": a.base_url, "rows": rows}, fh, indent=1) + if a.quiet: + n_ok, n_fatal, n_skip = summarise(rows) + print("served: %d files, %d OK, %d drift, %d skipped" + % (len(rows), n_ok, n_fatal, n_skip)) + return 1 if n_fatal else 0 + return report(rows) + + +if __name__ == "__main__": + sys.exit(main())