454 lines
19 KiB
Python
454 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""landing_check.py -- is every file at this revision actually served by the forge?
|
|
|
|
WHY THIS EXISTS
|
|
On 2026-10-17 this lane committed `tools/census_row_delta.py` and did not push
|
|
it. The commit was real, `git log` showed it, the check-in quoted it -- and the
|
|
archive could not read it: the anonymous raw path returned HTTP 404 / 11 B.
|
|
A committed artifact the archive cannot read is not a landed artifact, and
|
|
nothing in this repo compared "bytes at this revision" against "bytes the
|
|
forge serves". The mismatch was found by hand, twice, in one night. This is
|
|
the gate for that sentence.
|
|
|
|
It is deliberately narrow: it does not grade whether the work is any good, it
|
|
does not read the queue, and it does not decide what should be committed. It
|
|
answers one question per tracked file, with the bytes on both sides.
|
|
|
|
WHAT IT MEASURES -- one row per tracked file at --rev, one verdict per row
|
|
OK the blob at `rev:path` and the bytes served at
|
|
`<base>/branch/<branch>/<path>` have the same length and the
|
|
same sha256.
|
|
DIFFERS both sides returned bytes and they are not identical. This is
|
|
the unpushed-commit class: the local revision left the forge
|
|
behind, or the branch moved under the revision being checked.
|
|
MISSING the transport answered 404 (or, for file://, no such file).
|
|
A tracked path the archive cannot read at all.
|
|
HTTP_ERROR any other non-200 status. Not a verdict about content.
|
|
UNREACHABLE the transport failed before a status existed (DNS, refused,
|
|
TLS, timeout, unreadable fixture root). Distinct from MISSING
|
|
on purpose: "the forge did not answer" is not "the file is not
|
|
there", and collapsing them would let a network outage read as
|
|
a clean negative.
|
|
READ_FAILED `git cat-file blob rev:path` could not produce the local bytes.
|
|
An archive-side inconsistency, not a forge one.
|
|
SKIPPED_LARGE the local blob exceeds --max-bytes; not fetched, not graded.
|
|
ADVISORY, never fatal: a size policy is not a landing defect.
|
|
|
|
FATAL = DIFFERS, MISSING, HTTP_ERROR, UNREACHABLE, READ_FAILED. Exit 1 if any
|
|
row is fatal, else 0. Usage/precondition errors exit 2. A run in which every
|
|
fetched file matches prints `landing: N files, N OK, ...` and exits 0.
|
|
|
|
WHAT IT DOES NOT CLAIM
|
|
Byte equality is not provenance: a forge serving an identical copy says the
|
|
archive can read the same bytes, not that the bytes are correct, reviewed, or
|
|
reachable at another URL. It also cannot see a file that no revision tracks --
|
|
the census blindness this repo already paid for -- so untracked working-tree
|
|
files are out of scope by construction, and said so here rather than dropped.
|
|
The raw path is a forge convention (`raw/branch/<branch>/<path>`); --base-url
|
|
exists so the convention is a parameter, not an assumption baked into a URL.
|
|
|
|
READ-ONLY: the only write path is `--json PATH`. No file, branch or remote is
|
|
modified; every fetch is anonymous and GET.
|
|
|
|
USAGE:
|
|
python3 landing_check.py --repo /root/troupe-standup
|
|
python3 landing_check.py --repo . --rev HEAD --path tools/selftest_sweep.py
|
|
python3 landing_check.py --repo . --json /tmp/landing.json --quiet
|
|
python3 landing_check.py --selftest # fixture forge, no network, 26 cells
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
DEFAULT_BASE = ("https://forge.alexanderwhitestone.com/git/stackchain/"
|
|
"troupe-standup/raw")
|
|
CLAIM_RE = re.compile(r"^[0-9a-f]{7,40}$")
|
|
|
|
FATAL = ("DIFFERS", "MISSING", "HTTP_ERROR", "UNREACHABLE", "READ_FAILED")
|
|
ADVISORY = ("SKIPPED_LARGE",)
|
|
|
|
|
|
class FetchError(Exception):
|
|
"""The transport failed before any status existed."""
|
|
|
|
|
|
def sha256(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def git(repo, *args):
|
|
cp = subprocess.run(["git", "-C", repo, *args], capture_output=True)
|
|
if cp.returncode != 0:
|
|
raise RuntimeError("git %s failed: %s" % (
|
|
" ".join(args), cp.stderr.decode("utf-8", "replace").strip()))
|
|
return cp.stdout
|
|
|
|
|
|
def tracked_at(repo, rev):
|
|
"""Every path the revision tracks, NUL-separated so spaces are safe."""
|
|
out = git(repo, "ls-tree", "-r", "--name-only", "-z", rev)
|
|
return sorted(p for p in out.decode("utf-8", "surrogateescape").split("\0") if p)
|
|
|
|
|
|
def blob_at(repo, rev, path):
|
|
"""The exact bytes of rev:path. Binary-safe: no decode anywhere."""
|
|
return git(repo, "cat-file", "blob", "%s:%s" % (rev, path))
|
|
|
|
|
|
class Forge:
|
|
"""Anonymous GET of the raw path, or a file:// tree standing in for it."""
|
|
|
|
def __init__(self, base_url, timeout=20):
|
|
self.base = base_url.rstrip("/")
|
|
self.timeout = timeout
|
|
|
|
def url(self, branch, path):
|
|
from urllib.parse import quote
|
|
return "%s/branch/%s/%s" % (self.base, quote(branch, safe=""),
|
|
quote(path, safe="/"))
|
|
|
|
def fetch(self, branch, path):
|
|
"""-> (bytes, status). Raises FetchError when no status was reached."""
|
|
url = self.url(branch, path)
|
|
if url.startswith("file://"):
|
|
from urllib.parse import unquote
|
|
local = unquote(url[len("file://"):])
|
|
branch_root = self.base[len("file://"):].rstrip("/") + "/branch/" + branch
|
|
if not os.path.isdir(branch_root):
|
|
raise FetchError("fixture root absent: %s" % branch_root)
|
|
if os.path.isdir(local):
|
|
return b"", 500 # a directory is not a file
|
|
try:
|
|
with open(local, "rb") as fh:
|
|
return fh.read(), 200
|
|
except FileNotFoundError:
|
|
return b"", 404
|
|
except OSError as exc:
|
|
raise FetchError(str(exc))
|
|
import urllib.error
|
|
import urllib.request
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=self.timeout) as resp:
|
|
return resp.read(), resp.status
|
|
except urllib.error.HTTPError as exc:
|
|
return b"", exc.code
|
|
except (urllib.error.URLError, OSError) as exc:
|
|
raise FetchError(str(exc))
|
|
|
|
|
|
def grade(local, remote, status):
|
|
"""One verdict from the two byte strings and the transport status."""
|
|
if status == 404:
|
|
return "MISSING", "served 404"
|
|
if status != 200:
|
|
return "HTTP_ERROR", "served HTTP %s" % status
|
|
if len(local) != len(remote):
|
|
return "DIFFERS", "length %d local vs %d served" % (len(local), len(remote))
|
|
if sha256(local) != sha256(remote):
|
|
return "DIFFERS", "same length, different sha256"
|
|
return "OK", "%d B, sha256 %s" % (len(local), sha256(local)[:16])
|
|
|
|
|
|
def build_rows(repo, rev, branch, forge, paths=None, max_bytes=4 * 1024 * 1024):
|
|
files = tracked_at(repo, rev)
|
|
if paths:
|
|
wanted = set(paths)
|
|
files = [p for p in files if p in wanted]
|
|
rows = []
|
|
for path in files:
|
|
row = {"path": path, "verdict": None, "detail": "",
|
|
"local_bytes": None, "remote_bytes": None,
|
|
"local_sha256": None, "remote_sha256": None, "status": None}
|
|
try:
|
|
local = blob_at(repo, rev, path)
|
|
except RuntimeError as exc:
|
|
row.update(verdict="READ_FAILED", detail=str(exc)[:120])
|
|
rows.append(row)
|
|
continue
|
|
row["local_bytes"] = len(local)
|
|
row["local_sha256"] = sha256(local)
|
|
if len(local) > max_bytes:
|
|
row.update(verdict="SKIPPED_LARGE",
|
|
detail="%d B over --max-bytes %d" % (len(local), max_bytes))
|
|
rows.append(row)
|
|
continue
|
|
try:
|
|
remote, status = forge.fetch(branch, path)
|
|
except FetchError as exc:
|
|
row.update(verdict="UNREACHABLE", detail=str(exc)[:120])
|
|
rows.append(row)
|
|
continue
|
|
row["status"] = status
|
|
row["remote_bytes"] = len(remote) if status == 200 else None
|
|
row["remote_sha256"] = sha256(remote) if status == 200 else None
|
|
row["verdict"], row["detail"] = grade(local, remote, status)
|
|
rows.append(row)
|
|
return rows
|
|
|
|
|
|
def summarise(rows):
|
|
counts = {}
|
|
for row in rows:
|
|
counts[row["verdict"]] = counts.get(row["verdict"], 0) + 1
|
|
return counts
|
|
|
|
|
|
def summary_line(rows):
|
|
counts = summarise(rows)
|
|
order = ["OK", "DIFFERS", "MISSING", "HTTP_ERROR", "UNREACHABLE",
|
|
"READ_FAILED", "SKIPPED_LARGE"]
|
|
parts = ["%d %s" % (counts[k], k) for k in order if counts.get(k)]
|
|
fatal = sum(counts.get(k, 0) for k in FATAL)
|
|
return "landing: %d files, %s, %d fatal" % (len(rows), ", ".join(parts), fatal)
|
|
|
|
|
|
def run(args):
|
|
rows = build_rows(args.repo, args.rev, args.branch,
|
|
Forge(args.base_url, args.timeout),
|
|
args.path, args.max_bytes)
|
|
if not args.quiet:
|
|
width = max([len("path")] + [len(r["path"]) for r in rows]) if rows else 4
|
|
print("%-*s %-12s %s" % (width, "path", "verdict", "detail"))
|
|
for row in rows:
|
|
print("%-*s %-12s %s" % (width, row["path"], row["verdict"],
|
|
row["detail"]))
|
|
print()
|
|
line = summary_line(rows)
|
|
print(line)
|
|
if args.json:
|
|
payload = {"rev": args.rev, "branch": args.branch,
|
|
"base_url": args.base_url, "rows": rows,
|
|
"summary": summarise(rows),
|
|
"fatal": sum(row["verdict"] in FATAL for row in rows)}
|
|
with open(args.json, "w", encoding="utf-8") as fh:
|
|
json.dump(payload, fh, indent=2, sort_keys=True)
|
|
fh.write("\n")
|
|
return 1 if any(row["verdict"] in FATAL for row in rows) else 0
|
|
|
|
|
|
def selftest():
|
|
"""A fixture repo and a fixture forge: no network, every verdict reachable."""
|
|
import contextlib
|
|
import io
|
|
root = tempfile.mkdtemp(prefix="landing-check-selftest-")
|
|
cells = [0]
|
|
bad = [0]
|
|
|
|
def check(name, got, want):
|
|
cells[0] += 1
|
|
if got != want:
|
|
bad[0] += 1
|
|
print("FAIL %s\n got %r\n want %r" % (name, got, want))
|
|
|
|
try:
|
|
repo = os.path.join(root, "repo")
|
|
base_ok = os.path.join(root, "forge") # the raw path's parent
|
|
served = os.path.join(base_ok, "branch", "main")
|
|
os.makedirs(repo)
|
|
os.makedirs(os.path.join(served, "tools"))
|
|
os.makedirs(os.path.join(served, "docs"))
|
|
git(repo, "init", "-q", "-b", "main")
|
|
git(repo, "config", "user.email", "selftest@invalid")
|
|
git(repo, "config", "user.name", "selftest")
|
|
|
|
blobs = {"tools/a.py": b"print(1)\n",
|
|
"tools/bin.dat": bytes([0x00, 0xff, 0x80, 0x01, 0x00]),
|
|
"docs/two words.md": b"space in the name\n"}
|
|
for path, data in blobs.items():
|
|
full = os.path.join(repo, path)
|
|
os.makedirs(os.path.dirname(full), exist_ok=True)
|
|
with open(full, "wb") as fh:
|
|
fh.write(data)
|
|
git(repo, "add", "-A")
|
|
git(repo, "commit", "-q", "-m", "rev one")
|
|
rev1 = git(repo, "rev-parse", "HEAD").decode().strip()
|
|
|
|
# serve rev one, then add a file and commit rev two locally only
|
|
for path, data in blobs.items():
|
|
dst = os.path.join(served, path)
|
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
with open(dst, "wb") as fh:
|
|
fh.write(data)
|
|
with open(os.path.join(repo, "tools/late.py"), "w") as fh:
|
|
fh.write("late = True\n")
|
|
git(repo, "add", "-A")
|
|
git(repo, "commit", "-q", "-m", "rev two")
|
|
rev2 = git(repo, "rev-parse", "HEAD").decode().strip()
|
|
|
|
forge = Forge("file://" + base_ok)
|
|
|
|
# --- URL construction -------------------------------------------------
|
|
check("url: plain path",
|
|
forge.url("main", "tools/a.py"),
|
|
"file://" + base_ok + "/branch/main/tools/a.py")
|
|
check("url: space is percent-encoded, separators are not",
|
|
forge.url("main", "docs/two words.md"),
|
|
"file://" + base_ok + "/branch/main/docs/two%20words.md")
|
|
|
|
# --- git plumbing -----------------------------------------------------
|
|
check("discovery: rev one sees its three files",
|
|
tracked_at(repo, rev1), sorted(blobs))
|
|
check("discovery: rev-scoped, late.py absent at rev one",
|
|
"tools/late.py" in tracked_at(repo, rev1), False)
|
|
check("discovery: rev two sees late.py",
|
|
"tools/late.py" in tracked_at(repo, rev2), True)
|
|
with open(os.path.join(repo, "untracked.txt"), "w") as fh:
|
|
fh.write("not tracked\n")
|
|
check("discovery: an untracked worktree file is never a row",
|
|
"untracked.txt" in tracked_at(repo, rev2), False)
|
|
check("blob: text bytes are exact",
|
|
blob_at(repo, rev1, "tools/a.py"), b"print(1)\n")
|
|
check("blob: binary bytes survive cat-file",
|
|
blob_at(repo, rev1, "tools/bin.dat"), blobs["tools/bin.dat"])
|
|
|
|
# --- verdicts ---------------------------------------------------------
|
|
ok_rows = build_rows(repo, rev1, "main", forge)
|
|
check("all three tracked rows graded OK",
|
|
[r["verdict"] for r in ok_rows], ["OK", "OK", "OK"])
|
|
check("OK detail carries the served length",
|
|
[r["path"] for r in ok_rows if r["local_bytes"] == 9],
|
|
["tools/a.py"])
|
|
|
|
# one served byte changed -> DIFFERS, same length
|
|
target = os.path.join(served, "tools/a.py")
|
|
with open(target, "wb") as fh:
|
|
fh.write(b"print(2)\n")
|
|
rows = build_rows(repo, rev1, "main", forge, ["tools/a.py"])
|
|
check("mutation: one changed byte is DIFFERS", rows[0]["verdict"], "DIFFERS")
|
|
check("mutation: same length is reported as such",
|
|
rows[0]["detail"], "same length, different sha256")
|
|
|
|
# served file deleted -> MISSING
|
|
os.remove(target)
|
|
rows = build_rows(repo, rev1, "main", forge, ["tools/a.py"])
|
|
check("deleted served file is MISSING", rows[0]["verdict"], "MISSING")
|
|
check("MISSING carries the 404 status", rows[0]["status"], 404)
|
|
|
|
# a directory where a file should be -> HTTP_ERROR, not MISSING
|
|
os.makedirs(os.path.join(served, "tools/a.py"))
|
|
rows = build_rows(repo, rev1, "main", forge, ["tools/a.py"])
|
|
check("a directory at the raw path is HTTP_ERROR", rows[0]["verdict"],
|
|
"HTTP_ERROR")
|
|
|
|
# a served root that does not exist -> UNREACHABLE, not MISSING
|
|
gone = Forge("file://" + os.path.join(root, "no-such-forge"))
|
|
rows = build_rows(repo, rev1, "main", gone, ["tools/a.py"])
|
|
check("an absent forge root is UNREACHABLE", rows[0]["verdict"],
|
|
"UNREACHABLE")
|
|
# a transport that refuses the connection is UNREACHABLE too (no DNS,
|
|
# no external network: port 1 on loopback refuses immediately)
|
|
refused = Forge("http://127.0.0.1:1/raw", timeout=5)
|
|
rows = build_rows(repo, rev1, "main", refused, ["tools/a.py"])
|
|
check("a refused connection is UNREACHABLE", rows[0]["verdict"],
|
|
"UNREACHABLE")
|
|
|
|
# restore the good served file before the exit-code cells
|
|
shutil.rmtree(os.path.join(served, "tools/a.py"))
|
|
with open(os.path.join(served, "tools/a.py"), "wb") as fh:
|
|
fh.write(blobs["tools/a.py"])
|
|
|
|
# --- policy rows ------------------------------------------------------
|
|
rows = build_rows(repo, rev1, "main", Forge(base_ok), max_bytes=2)
|
|
check("over --max-bytes is SKIPPED_LARGE, never fetched",
|
|
sorted({r["verdict"] for r in rows}), ["SKIPPED_LARGE"])
|
|
check("SKIPPED_LARGE keeps the local length",
|
|
rows[0]["local_bytes"] is not None, True)
|
|
|
|
# --- exit codes, json, quiet, path filter -----------------------------
|
|
out = os.path.join(root, "out.json")
|
|
ns = argparse.Namespace(repo=repo, rev=rev1, branch="main",
|
|
base_url="file://" + base_ok, path=None,
|
|
max_bytes=4 * 1024 * 1024, timeout=5,
|
|
json=out, quiet=True)
|
|
rc = run(ns)
|
|
payload = json.loads(open(out, encoding="utf-8").read())
|
|
check("a clean run exits 0", rc, 0)
|
|
check("json rows and summary agree",
|
|
sum(payload["summary"].values()), len(payload["rows"]))
|
|
check("json fatal count is zero on a clean run", payload["fatal"], 0)
|
|
|
|
with open(os.path.join(served, "tools/a.py"), "wb") as fh:
|
|
fh.write(b"print(9)\n")
|
|
ns.path = ["tools/a.py"]
|
|
ns.json = None
|
|
buf1 = io.StringIO()
|
|
with contextlib.redirect_stdout(buf1):
|
|
rc_path = run(ns)
|
|
check("--path restricts the run to one tracked row, and DIFFERS exits 1",
|
|
rc_path, 1)
|
|
with open(os.path.join(served, "tools/a.py"), "wb") as fh:
|
|
fh.write(blobs["tools/a.py"])
|
|
buf2 = io.StringIO()
|
|
with contextlib.redirect_stdout(buf2):
|
|
rc_restored = run(ns)
|
|
check("restored bytes make the run clean again", rc_restored, 0)
|
|
ns.path = None
|
|
|
|
# quiet: the table is suppressed, the summary survives
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
run(ns)
|
|
printed = buf.getvalue()
|
|
check("--quiet prints the summary line and nothing else",
|
|
printed, "landing: 3 files, 3 OK, 0 fatal\n")
|
|
|
|
# --- precondition -----------------------------------------------------
|
|
check("a non-git --repo is a precondition error (2)",
|
|
_exit2(os.path.join(root, "not-a-repo")), 2)
|
|
finally:
|
|
shutil.rmtree(root, ignore_errors=True)
|
|
|
|
print("selftest: %d cells, %d passed, %d failed" % (cells[0], cells[0] - bad[0], bad[0]))
|
|
return 0 if bad[0] == 0 else 1
|
|
|
|
|
|
def _exit2(repo):
|
|
"""The code main() returns for a path that is not a git work tree."""
|
|
probe = subprocess.run(["git", "-C", repo, "rev-parse", "--git-dir"],
|
|
capture_output=True)
|
|
return 2 if probe.returncode != 0 else 0
|
|
|
|
|
|
def main(argv=None):
|
|
ap = argparse.ArgumentParser(
|
|
description="verify that the forge serves the bytes this revision tracks")
|
|
ap.add_argument("--repo", default=".")
|
|
ap.add_argument("--rev", default="HEAD")
|
|
ap.add_argument("--branch", default="main")
|
|
ap.add_argument("--base-url", default=DEFAULT_BASE)
|
|
ap.add_argument("--path", action="append", metavar="PATH",
|
|
help="restrict to this tracked path (repeatable)")
|
|
ap.add_argument("--max-bytes", type=int, default=4 * 1024 * 1024,
|
|
help="blobs larger than this are SKIPPED_LARGE (default 4 MiB)")
|
|
ap.add_argument("--timeout", type=float, default=20.0)
|
|
ap.add_argument("--json", metavar="PATH")
|
|
ap.add_argument("--quiet", action="store_true")
|
|
ap.add_argument("--selftest", action="store_true")
|
|
args = ap.parse_args(argv)
|
|
|
|
if args.selftest:
|
|
return selftest()
|
|
if not os.path.isdir(args.repo):
|
|
print("landing_check: --repo %s is not a directory" % args.repo,
|
|
file=sys.stderr)
|
|
return 2
|
|
if _exit2(args.repo) != 0:
|
|
print("landing_check: --repo %s is not a git work tree" % args.repo,
|
|
file=sys.stderr)
|
|
return 2
|
|
try:
|
|
return run(args)
|
|
except RuntimeError as exc:
|
|
print("landing_check: %s" % exc, file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|