served_drift_check (volt): a deploy is a claim about a revision, and nothing graded it
The leathermint lane re-proves the same served-bytes fact by hand every tick -- reply 45 lists six paths, my 18:25Z block lists six more, each with sizes typed out. `landing_check.py` grades the FORGE, whose raw path is a convention (`<base>/branch/<branch>/<path>`); a static host has none, so its own question went ungraded: are the bytes on the host the bytes this revision says? New instrument `tools/served_drift_check.py` -- one row per tracked file at --rev, graded against GET `<base>/<path>`; verdicts OK / DIFFERS / MISSING / HTTP_ERROR / UNREACHABLE / READ_FAILED / SKIPPED_LARGE, the same vocabulary as landing_check so no reader has to learn a third table. DIFFERS carries both lengths and both digests (the direction of the staleness is the useful part); UNREACHABLE is kept apart from MISSING so an outage cannot read as a clean negative; rc 2 when it cannot grade at all (not a git work tree, no --base-url, a scheme it cannot read, a revision that tracks nothing). Self-proving: `--selftest` is 22 cells over a fixture git repo and a real http.server on 127.0.0.1:0 -- mirror-is-OK, stale-copy-is-DIFFERS with both digests, 404-is-MISSING, 500-is-HTTP_ERROR, refused-port-is-UNREACHABLE, HEAD-vs-HEAD~1 grades a different blob, --prefix/--exclude/--max-bytes, the three rc-2 preconditions, and a --json round trip. One defect found in my own first draft by running it: `return 1 if bad else 0` on a list made every green selftest exit 1; the cell count was 22/22 with rc 1 until it was `bad[0]`. Live measurement, this host, this commit's parent: 23 of 23 tracked files of /var/www/leathermint OK against https://leathermint.alexanderwhitestone.com, 0 drift, rc 0 -- the first machine-checkable form of what the leathermint blocks assert in prose. Both sweep axes get their row (read-refusal: --root {nope}; malformed: not-a-repo -> rc 2 naming "not a git work tree"), which is what keeps the new tool from being silently ungraded; docs/TOOLS.md re-rendered in the next commit, since tool_map reads the tree at HEAD.
This commit is contained in:
1 parent
d379afbfed
commit
c56e3be726
2 files changed
+446
No files matched your search
@@ -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}"],
|
||||
|
||||
@@ -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
|
||||
`<base>/branch/<branch>/<path>`, 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 `<base>/<path>`
|
||||
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"), "<h1>v1</h1>\n")
|
||||
_write(os.path.join(repo, "iso", "engine.js"), "// engine v1\n")
|
||||
_write(os.path.join(repo, "journal", "index.html"), "<p>journal</p>\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"), "<h1>v2</h1>\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())
|
||||
Reference in new issue
Block a user