mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
The connectivity canary fetched https://pypi.org/simple/ with the suite's
ten-second read timeout. That path is the complete PyPI index: measured at
45 MB here, it times out long before it finishes, and the suite then aborts
with "This host cannot reach the infrastructure the repository depends on"
even though pypi.org answers 200. _probe_url uses requests.get, so the whole
body is downloaded to decide a yes/no question.
https://pypi.org/simple/ansible/ answers the same question against the same
host and service in 199 KB and 0.41s, and ansible is a declared dependency in
pyproject.toml, so the canary still sits on a path this repository actually
depends on.
The guard keeps its teeth. 7b5c462af2 added it because a networkless run had
reported green over 244 unanswered probes, 110 of them real 404s; an
unreachable pypi.org still fails this canary and still aborts the suite.
Only the volume needed to establish reachability changes.
The other two canaries were measured and left alone: registry.npmjs.org
returns 2 bytes in 0.25s and github.com is likewise small.
make test-external went from FAILED TARGETS with exit 2 to 11 passed.
513 lines
18 KiB
Python
513 lines
18 KiB
Python
"""Check literal HTTP(S) URLs in repository files.
|
|
|
|
Scans git-tracked and untracked-but-not-ignored text files for literal
|
|
``http://`` and ``https://`` URLs. Placeholder/template URLs and reserved
|
|
local/example hosts are skipped, as are ``.onion`` addresses, which no
|
|
clearnet probe can reach without a Tor SOCKS proxy this suite does not use.
|
|
Per-line suppression uses the unified
|
|
``# nocheck: url`` marker. See
|
|
``docs/contributing/actions/testing/suppression.md``.
|
|
|
|
This is an external test because it performs live HTTP requests against the
|
|
referenced third-party URLs. HTTP ``401`` (Unauthorized), ``403`` (Forbidden),
|
|
``405`` (Method Not Allowed), ``406`` (Not Acceptable) and ``415``
|
|
(Unsupported Media Type) are treated as reachable (server is alive but
|
|
auth-gated, method-restricted, or rejecting the probe's headers). HTTP ``418``
|
|
(I'm a teapot), ``429`` (Too Many Requests), ``451`` (Unavailable For Legal
|
|
Reasons) and every ``5xx`` server response emit warning annotations rather than
|
|
failing: the server answered, so the URL exists. All other ``4xx`` codes fail
|
|
once a second probe after a pause answers the same way.
|
|
|
|
A timeout or connection error is a third outcome, reported as an unverified
|
|
warning: the server never answered, so the URL was not checked at all. Such a
|
|
probe is indeterminate rather than negative -- a host may be geo-blocked, be a
|
|
rendered template placeholder, or simply be firewalled off from this runner.
|
|
|
|
That tolerance is what let a sandbox with no route to the registry report a
|
|
green run over 244 unanswered probes, among them 110 lockfile URLs that were in
|
|
fact HTTP 404. The guard against it is ``_CONNECTIVITY_CANARIES``: before any
|
|
probing, the suite checks the infrastructure hosts the repository actually
|
|
depends on, and fails outright when they are unreachable. A run that cannot
|
|
reach its subject does not pass; it does not run.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import concurrent.futures
|
|
import ipaddress
|
|
import os
|
|
import random
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import unittest
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
from typing import NamedTuple
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
import requests
|
|
|
|
from utils.annotations.message import error, warning
|
|
from utils.annotations.suppress import is_suppressed_at
|
|
from utils.cache.files import iter_non_ignored_files, read_text
|
|
|
|
from . import PROJECT_ROOT
|
|
|
|
_REPO_ROOT = PROJECT_ROOT
|
|
_URL_RE = re.compile(r"https?://[^\s<>'\"`\]]+")
|
|
_TEMPLATE_MARKERS = ("${", "{{", "}}", "{%", "%}")
|
|
_PUBLIC_HOST_RE = re.compile(r"^[A-Za-z0-9.-]+$")
|
|
_CONNECTIVITY_CANARIES = (
|
|
"https://github.com/",
|
|
"https://pypi.org/simple/ansible/",
|
|
"https://registry.npmjs.org/",
|
|
)
|
|
_RESERVED_HOSTS = {
|
|
"example",
|
|
"example.com",
|
|
"example.net",
|
|
"example.org",
|
|
"localhost",
|
|
"domain-example.com",
|
|
}
|
|
_RESERVED_HOST_SUFFIXES = (
|
|
".example",
|
|
".example.com",
|
|
".example.net",
|
|
".example.org",
|
|
".invalid",
|
|
".local",
|
|
".onion",
|
|
".localhost",
|
|
".localdomain",
|
|
".test",
|
|
".tld",
|
|
)
|
|
_OK_STATUS_CODES = {401, 403, 405, 406, 415}
|
|
_WARNING_STATUS_CODES = {418, 429, 451}
|
|
_REQUEST_TIMEOUT_SECONDS = 10
|
|
_FAIL_RETRY_DELAY_SECONDS = 5
|
|
_USER_AGENT = (
|
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
|
)
|
|
_MAX_WORKERS = int(os.environ["INFINITO_WORKER_FETCH"])
|
|
_GLOBAL_DEADLINE_SECONDS = 5 * 3600 + 30 * 60
|
|
_PROGRESS_INTERVAL = 50
|
|
|
|
|
|
class UrlOccurrence(NamedTuple):
|
|
file: Path
|
|
line: int
|
|
url: str
|
|
|
|
|
|
class ProbeOutcome(NamedTuple):
|
|
kind: str
|
|
detail: str
|
|
|
|
|
|
def _repo_files(root: Path) -> list[Path]:
|
|
"""Return git-tracked and untracked-but-not-ignored files."""
|
|
try:
|
|
out = subprocess.check_output(
|
|
[
|
|
"git",
|
|
"-C",
|
|
str(root),
|
|
"ls-files",
|
|
"-z",
|
|
"--cached",
|
|
"--others",
|
|
"--exclude-standard",
|
|
],
|
|
stderr=subprocess.STDOUT,
|
|
)
|
|
except Exception: # pragma: no cover - git is expected in tests
|
|
return _repo_files_without_git_metadata(root)
|
|
|
|
rel_paths = [p for p in out.decode("utf-8", errors="replace").split("\0") if p]
|
|
return sorted(root / rel for rel in rel_paths if (root / rel).is_file())
|
|
|
|
|
|
def _repo_files_without_git_metadata(root: Path) -> list[Path]:
|
|
"""Fallback scan for environments where the checkout omits .git metadata.
|
|
Routes through `utils.cache.files.iter_non_ignored_files` so the walk
|
|
+ .gitignore matching are shared with every other lint test."""
|
|
return sorted(Path(p) for p in iter_non_ignored_files(root=str(root)))
|
|
|
|
|
|
def _is_probably_text_file(path: Path) -> bool:
|
|
"""Skip binary files so the scan stays fast and relevant."""
|
|
try:
|
|
with path.open("rb") as handle:
|
|
sample = handle.read(4096)
|
|
except OSError:
|
|
return False
|
|
return b"\0" not in sample
|
|
|
|
|
|
def _strip_unbalanced_suffix(url: str, opener: str, closer: str) -> str:
|
|
"""Trim a trailing closer that belongs to surrounding markup, not the URL."""
|
|
while url.endswith(closer) and url.count(opener) < url.count(closer):
|
|
url = url[:-1]
|
|
return url
|
|
|
|
|
|
def _truncate_at_second_scheme(url: str) -> str:
|
|
"""Cut off concatenated URLs like ``foo:https://bar`` after the first URL."""
|
|
positions = [
|
|
pos for pos in (url.find("http://", 1), url.find("https://", 1)) if pos >= 0
|
|
]
|
|
if not positions:
|
|
return url
|
|
return url[: min(positions)].rstrip(",:")
|
|
|
|
|
|
def _truncate_at_markup_boundary(url: str) -> str:
|
|
"""Stop at the first unmatched markdown/prose closer after the URL."""
|
|
result: list[str] = []
|
|
paren_depth = 0
|
|
for char in url:
|
|
if char == ")":
|
|
if paren_depth == 0:
|
|
break
|
|
paren_depth -= 1
|
|
elif char == "]":
|
|
break
|
|
elif char == "(":
|
|
paren_depth += 1
|
|
result.append(char)
|
|
return "".join(result)
|
|
|
|
|
|
def _normalize_url(raw: str) -> str:
|
|
"""Trim common trailing punctuation added by prose or markup."""
|
|
url = _truncate_at_second_scheme(raw)
|
|
url = _truncate_at_markup_boundary(url)
|
|
url = url.rstrip(".,;:")
|
|
for escaped in (r"\n", r"\r"):
|
|
while url.endswith(escaped):
|
|
url = url[: -len(escaped)]
|
|
for opener, closer in (("(", ")"), ("[", "]"), ("{", "}")):
|
|
url = _strip_unbalanced_suffix(url, opener, closer)
|
|
return url
|
|
|
|
|
|
def _is_live_literal_url(url: str) -> bool:
|
|
"""Return True when *url* is a literal public HTTP(S) URL worth probing."""
|
|
if "…" in url:
|
|
return False
|
|
if any(marker in url for marker in _TEMPLATE_MARKERS):
|
|
return False
|
|
if any(char in url for char in "{}$"):
|
|
return False
|
|
|
|
try:
|
|
parsed = urlsplit(url)
|
|
except ValueError:
|
|
return False
|
|
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
return False
|
|
|
|
host = (parsed.hostname or "").lower()
|
|
if not host:
|
|
return False
|
|
if "." not in host:
|
|
return False
|
|
if not _PUBLIC_HOST_RE.match(host):
|
|
return False
|
|
if host.split(".", 1)[0] == "example":
|
|
return False
|
|
if host in _RESERVED_HOSTS:
|
|
return False
|
|
if any(host.endswith(suffix) for suffix in _RESERVED_HOST_SUFFIXES):
|
|
return False
|
|
|
|
try:
|
|
ip = ipaddress.ip_address(host)
|
|
except ValueError:
|
|
return True
|
|
|
|
return not (
|
|
ip.is_loopback
|
|
or ip.is_link_local
|
|
or ip.is_multicast
|
|
or ip.is_private
|
|
or ip.is_reserved
|
|
or ip.is_unspecified
|
|
)
|
|
|
|
|
|
def _extract_urls(path: Path) -> list[UrlOccurrence]:
|
|
"""Return URL occurrences from *path* that should be probed live."""
|
|
if not _is_probably_text_file(path):
|
|
return []
|
|
|
|
try:
|
|
lines = read_text(str(path)).splitlines()
|
|
except (OSError, UnicodeDecodeError):
|
|
return []
|
|
|
|
occurrences: list[UrlOccurrence] = []
|
|
for line_no, line in enumerate(lines, start=1):
|
|
if is_suppressed_at(lines, line_no, "url"):
|
|
continue
|
|
for match in _URL_RE.finditer(line):
|
|
url = _normalize_url(match.group(0))
|
|
if _is_live_literal_url(url):
|
|
occurrences.append(UrlOccurrence(path, line_no, url))
|
|
return occurrences
|
|
|
|
|
|
def _probe_key(url: str) -> str:
|
|
"""Deduplicate URLs by dropping fragments, which do not affect HTTP reachability."""
|
|
parsed = urlsplit(url)
|
|
return urlunsplit(parsed._replace(fragment=""))
|
|
|
|
|
|
def _probe_url(url: str) -> ProbeOutcome:
|
|
"""Probe one URL and classify the result for external-test stability."""
|
|
try:
|
|
response = requests.get(
|
|
url,
|
|
allow_redirects=False,
|
|
headers={"User-Agent": _USER_AGENT},
|
|
stream=True,
|
|
timeout=_REQUEST_TIMEOUT_SECONDS,
|
|
)
|
|
try:
|
|
status = response.status_code
|
|
finally:
|
|
response.close()
|
|
except requests.Timeout as exc:
|
|
return ProbeOutcome("unverified", f"Timeout: {exc}")
|
|
except requests.ConnectionError as exc:
|
|
return ProbeOutcome("unverified", f"ConnectionError: {exc}")
|
|
except requests.RequestException as exc:
|
|
return ProbeOutcome("fail", f"{type(exc).__name__}: {exc}")
|
|
except Exception as exc: # pragma: no cover - defensive safety net
|
|
return ProbeOutcome("fail", f"{type(exc).__name__}: {exc}")
|
|
|
|
if status < 400 or status in _OK_STATUS_CODES:
|
|
return ProbeOutcome("ok", f"HTTP {status}")
|
|
if status in _WARNING_STATUS_CODES or status >= 500:
|
|
return ProbeOutcome("warn", f"HTTP {status}")
|
|
return ProbeOutcome("fail", f"HTTP {status}")
|
|
|
|
|
|
def _probe_canary(url: str) -> ProbeOutcome:
|
|
"""Probe a connectivity canary, retrying once so one blip cannot fail the run."""
|
|
outcome = _probe_url(url)
|
|
if outcome.kind == "unverified":
|
|
outcome = _probe_url(url)
|
|
return outcome
|
|
|
|
|
|
def _probe_with_retry(url: str) -> ProbeOutcome:
|
|
"""Probe one URL; a failing HTTP status is confirmed by a second probe after a pause."""
|
|
outcome = _probe_url(url)
|
|
if outcome.kind == "fail" and outcome.detail.startswith("HTTP "):
|
|
time.sleep(_FAIL_RETRY_DELAY_SECONDS)
|
|
outcome = _probe_url(url)
|
|
return outcome
|
|
|
|
|
|
def _collect_occurrences(root: Path) -> dict[str, list[UrlOccurrence]]:
|
|
"""Collect probe-worthy URLs from all non-ignored repository files."""
|
|
by_url: dict[str, list[UrlOccurrence]] = defaultdict(list)
|
|
for path in _repo_files(root):
|
|
for occurrence in _extract_urls(path):
|
|
by_url[_probe_key(occurrence.url)].append(occurrence)
|
|
return dict(by_url)
|
|
|
|
|
|
class TestUrlsReachable(unittest.TestCase):
|
|
"""Literal public HTTP(S) URLs in repository files should stay reachable."""
|
|
|
|
def test_public_http_urls_are_reachable(self) -> None:
|
|
occurrences_by_url = _collect_occurrences(_REPO_ROOT)
|
|
self.assertTrue(
|
|
occurrences_by_url,
|
|
"No probe-worthy public HTTP(S) URLs found in repository files.",
|
|
)
|
|
|
|
unreachable_canaries = [
|
|
(url, outcome.detail)
|
|
for url, outcome in ((c, _probe_canary(c)) for c in _CONNECTIVITY_CANARIES)
|
|
if outcome.kind == "unverified"
|
|
]
|
|
if unreachable_canaries:
|
|
self.fail(
|
|
"This host cannot reach the infrastructure the repository depends"
|
|
" on, so nothing below would actually be verified:\n"
|
|
+ "\n".join(
|
|
f" {url} -> {detail}" for url, detail in unreachable_canaries
|
|
)
|
|
+ "\n\n Every other probe would fail the same way and be reported"
|
|
" as a warning, turning an unchecked run green. Run this suite from"
|
|
" a host with a route to the internet."
|
|
)
|
|
|
|
failing_found: list[tuple[str, UrlOccurrence, str, int]] = []
|
|
warnings_found: list[tuple[str, UrlOccurrence, str, int]] = []
|
|
unverified_found: list[tuple[str, UrlOccurrence, str, int]] = []
|
|
|
|
total = len(occurrences_by_url)
|
|
print(
|
|
f"Probing {total} URLs "
|
|
f"(workers={_MAX_WORKERS}, "
|
|
f"per-probe={_REQUEST_TIMEOUT_SECONDS}s, "
|
|
f"global={_GLOBAL_DEADLINE_SECONDS}s)...",
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
|
|
executor = concurrent.futures.ThreadPoolExecutor(max_workers=_MAX_WORKERS)
|
|
submission_order = sorted(occurrences_by_url)
|
|
random.shuffle(submission_order)
|
|
future_to_url = {
|
|
executor.submit(_probe_with_retry, url): url for url in submission_order
|
|
}
|
|
|
|
completed = 0
|
|
start = time.monotonic()
|
|
try:
|
|
for future in concurrent.futures.as_completed(
|
|
future_to_url, timeout=_GLOBAL_DEADLINE_SECONDS
|
|
):
|
|
probe_url = future_to_url[future]
|
|
outcome = future.result()
|
|
occurrences = occurrences_by_url[probe_url]
|
|
completed += 1
|
|
|
|
if completed % _PROGRESS_INTERVAL == 0 or completed == total:
|
|
elapsed = time.monotonic() - start
|
|
print(
|
|
f" [{completed}/{total}] elapsed={elapsed:.1f}s",
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
|
|
if outcome.kind == "fail":
|
|
failing_found.append(
|
|
(
|
|
"External URL failure",
|
|
occurrences[0],
|
|
f"{probe_url} -> {outcome.detail}",
|
|
len(occurrences),
|
|
)
|
|
)
|
|
continue
|
|
|
|
if outcome.kind == "unverified":
|
|
unverified_found.append(
|
|
(
|
|
"External URL unverified",
|
|
occurrences[0],
|
|
f"{probe_url} -> {outcome.detail}",
|
|
len(occurrences),
|
|
)
|
|
)
|
|
continue
|
|
|
|
if outcome.kind == "warn":
|
|
warnings_found.append(
|
|
(
|
|
"External URL reachability",
|
|
occurrences[0],
|
|
f"{probe_url} -> {outcome.detail}",
|
|
len(occurrences),
|
|
)
|
|
)
|
|
except concurrent.futures.TimeoutError:
|
|
elapsed = time.monotonic() - start
|
|
unfinished_urls = [u for f, u in future_to_url.items() if not f.done()]
|
|
for probe_url in unfinished_urls:
|
|
occurrences = occurrences_by_url[probe_url]
|
|
unverified_found.append(
|
|
(
|
|
"External URL unverified",
|
|
occurrences[0],
|
|
(
|
|
f"{probe_url} -> Timeout: global deadline "
|
|
f"{_GLOBAL_DEADLINE_SECONDS}s exceeded"
|
|
),
|
|
len(occurrences),
|
|
)
|
|
)
|
|
print(
|
|
f" global deadline reached at {elapsed:.1f}s; "
|
|
f"{len(unfinished_urls)} probes still running "
|
|
f"(counted as unverified, not waited for)",
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
finally:
|
|
executor.shutdown(wait=False, cancel_futures=True)
|
|
|
|
for title, occurrence, message, count in sorted(
|
|
failing_found,
|
|
key=lambda item: (item[1].file.as_posix(), item[1].line, item[2]),
|
|
):
|
|
rel = occurrence.file.relative_to(_REPO_ROOT).as_posix()
|
|
suffix = "" if count == 1 else f" ({count} occurrences)"
|
|
error(
|
|
f"{message}{suffix}",
|
|
title=title,
|
|
file=rel,
|
|
line=occurrence.line,
|
|
)
|
|
|
|
for title, occurrence, message, count in sorted(
|
|
warnings_found,
|
|
key=lambda item: (item[1].file.as_posix(), item[1].line, item[2]),
|
|
):
|
|
rel = occurrence.file.relative_to(_REPO_ROOT).as_posix()
|
|
suffix = "" if count == 1 else f" ({count} occurrences)"
|
|
warning(
|
|
f"{message}{suffix}",
|
|
title=title,
|
|
file=rel,
|
|
line=occurrence.line,
|
|
)
|
|
|
|
for title, occurrence, message, count in sorted(
|
|
unverified_found,
|
|
key=lambda item: (item[1].file.as_posix(), item[1].line, item[2]),
|
|
):
|
|
rel = occurrence.file.relative_to(_REPO_ROOT).as_posix()
|
|
suffix = "" if count == 1 else f" ({count} occurrences)"
|
|
warning(
|
|
f"{message}{suffix}",
|
|
title=title,
|
|
file=rel,
|
|
line=occurrence.line,
|
|
)
|
|
|
|
if not failing_found:
|
|
return
|
|
|
|
lines = [
|
|
f"Failing HTTP(S) URLs found ({len(failing_found)}):",
|
|
"",
|
|
" Fix the URL, remove it, or adjust the reference.",
|
|
" 401/403/405/406/415 = server alive (auth/method/headers). 418/429/451 + all 5xx = warning. Other 4xx = fail.",
|
|
"",
|
|
]
|
|
for _title, occurrence, message, count in sorted(
|
|
failing_found,
|
|
key=lambda item: (item[1].file.as_posix(), item[1].line, item[2]),
|
|
):
|
|
rel = occurrence.file.relative_to(_REPO_ROOT).as_posix()
|
|
suffix = "" if count == 1 else f" ({count} occurrences)"
|
|
lines.append(f" {rel}:{occurrence.line}: {message}{suffix}")
|
|
self.fail("\n".join(lines))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|