summaryrefslogtreecommitdiff
path: root/scripts/docs_gates/parity.py
diff options
context:
space:
mode:
authorYuriy Andamasov <yuriy@vyos.io>2026-09-25 17:11:19 +0200
committerGitHub <noreply@github.com>2026-09-25 16:11:19 +0100
commita783e56b774795c1f4bd8a6088dfcce96a307c5f (patch)
treedcd0c88ff02969351840c8222ea97c72d9258e18 /scripts/docs_gates/parity.py
parent4c8d1d1a0f23e2106a97f4b672607121ff03a091 (diff)
downloadvyos-documentation-a783e56b774795c1f4bd8a6088dfcce96a307c5f.tar.gz
vyos-documentation-a783e56b774795c1f4bd8a6088dfcce96a307c5f.zip
ci: port the Cloudflare Workers docs pipeline to circinus (slug 1.5) (#2208)
* ci: port Cloudflare Workers docs pipeline files to circinus (verbatim) Copies the branch-agnostic half of the docs.vyos.io Cloudflare Workers pipeline from `rolling` at 8cb568bf, byte-identical: - .github/workflows/docs-build.yml - workers/ (entire tree) - scripts/docs_gates/ - docker/im-convert.sh - docs/_static/js/version-picker.js, js/pagefind-wrapper.js, css/version-picker.css (new files, no circinus counterpart) - docs/_templates/breadcrumbs.html, searchbox.html (new files) docs-build.yml already triggers on push to [rolling, circinus, sagitta] and resolves `circinus` -> worker vyos-docs-v15-en / slug 1.5 from workers/matrix.json; the files simply did not exist on this branch, so slug 1.5 still serves the bootstrap placeholder. workers/versions.json + workers/matrix.json are deliberately identical across all three branches and must be kept in sync. Advances: IS-572 * ci: wire circinus docs build into the Cloudflare Workers pipeline Hand-merges the CF-specific hunks onto circinus's own docker/Dockerfile and docs/conf.py rather than clobbering them with rolling's versions β€” circinus keeps its own content-driven history in both files. docker/Dockerfile: - imagemagick + librsvg2-bin (sphinx.ext.imgconverter backend) and poppler-utils (pdfinfo, used by docs-build.yml's PDF page-count completeness check), installed --no-install-recommends - install docker/im-convert.sh as /usr/local/bin/im-convert docs/conf.py: - enable sphinx.ext.imgconverter + image_converter = 'im-convert' so the LaTeX/PDF builder stops silently dropping .webp/.svg images - register js/version-picker.js + css/version-picker.css unconditionally (degrades silently on ReadTheDocs) - _vyos_cf_build gate off the raw DOCS_VERSION_SLUG env var; only CF builds load js/pagefind-wrapper.js, and html_context['vyos_cf_build'] lets _templates/searchbox.html fall back to the stock Sphinx searchbox via the "!" bang-include on RTD The RTD path stays the default in both files: circinus continues building on ReadTheDocs until RTD sunset, and every CF feature activates only when DOCS_VERSION_SLUG is present. .readthedocs.yml is untouched. circinus keeps its own version/release/html_title/source_suffix and its hardcoded html_baseurl (its CF slug is also `1.5`, so rolling's DOCS_VERSION_SLUG/READTHEDOCS_VERSION resolution block is a no-op here and was deliberately not ported). Advances: IS-572 * ci: IS-572: re-sync ported Cloudflare Workers pipeline files with rolling The category-1 files in this port are byte-identical copies from `rolling`. `rolling` has since moved: [vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209) merged as `3a1c6c30`, thirteen rounds of hardening on exactly these files. Re-take all 14 category-1 paths from `origin/rolling` via `git checkout origin/rolling -- <paths>`, so byte-identity holds by construction rather than by hand-editing: .github/workflows/docs-build.yml scripts/docs_gates/{gates,parity,smoke,test_gates,test_parity,test_smoke}.py workers/.gitignore workers/apex/src/{index,special,uagate}.ts workers/apex/test/{router,uagate}.test.ts workers/apex/ua-policy.json Thirteen of the fourteen carry [vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209) exactly β€” the pre-change tree was byte-identical to `3a1c6c30^` for those paths. `workers/.gitignore` additionally picks up the one-line `test-results/` entry from [vyos-documentation#2212](https://github.com/vyos/vyos-documentation/pull/2212); inert on circinus, since only the deliberately-unported `apex-deploy.yml` writes that directory. Deliberate exclusions are unchanged: `docs-canary-qa.yml` (cron runs on the default branch only, so it is not ported even though [vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209) touched it on `rolling`), `apex-deploy.yml`, and the `docs-preview-*` workflows. `docs/conf.py` stays hand-merged and circinus-specific, with its ReadTheDocs fallback intact. πŸ€– Generated by [robots](https://vyos.io)
Diffstat (limited to 'scripts/docs_gates/parity.py')
-rw-r--r--scripts/docs_gates/parity.py253
1 files changed, 253 insertions, 0 deletions
diff --git a/scripts/docs_gates/parity.py b/scripts/docs_gates/parity.py
new file mode 100644
index 00000000..c30536db
--- /dev/null
+++ b/scripts/docs_gates/parity.py
@@ -0,0 +1,253 @@
+"""URL-parity corpus + alias assertions (spec Β§11).
+
+Modes:
+ --sitemap-host X pull per-version sitemaps from X (RTD pre-cutover)
+ --probe-host Y probe every URL on Y (canary via Access, or production)
+Fails (exit 1) on any status mismatch (non-200 for corpus rows; wrong
+Location for alias rows).
+"""
+from __future__ import annotations
+
+import argparse
+import dataclasses
+import json
+import os
+import re
+import sys
+import urllib.parse
+import urllib.request
+from pathlib import Path
+
+SITEMAP_LOC = re.compile(r"<loc>([^<]+)</loc>")
+
+# Sitemap sweep covers only the versions THIS repo builds on CF (rolling/1.5/1.4).
+# 1.3/1.2 have NO RTD sitemaps (spec Β§15a.5) β€” their parity is the legacy snapshot
+# repo's crawl-inventory job. Their alias/PDF redirect rows below stay in scope.
+DEFAULT_SLUGS = "rolling,1.5,1.4"
+
+ALIASES = [("latest", "rolling"), ("stable", "1.5"), ("lts", "1.5"),
+ ("circinus", "1.5"), ("sagitta", "1.4"), ("equuleus", "1.3"), ("crux", "1.2")]
+
+
+def urls_from_sitemap(xml: str) -> list[str]:
+ out = []
+ for loc in SITEMAP_LOC.findall(xml):
+ path = re.sub(r"^https?://[^/]+", "", loc)
+ if path.startswith("/en/"):
+ out.append(path)
+ return out
+
+
+def alias_corpus() -> list[tuple[str, int, str]]:
+ rows = [(f"/en/{a}/", 301, f"/en/{s}/") for a, s in ALIASES]
+ # 1.2 excluded: never had an RTD PDF artifact (Phase-0 finding, spec Β§15a) β€” pdf: null
+ rows += [(f"/_/downloads/en/{s}/pdf/", 301, f"/en/{s}/vyos-documentation.pdf")
+ for s in ["rolling", "1.5", "1.4", "1.3"]]
+ rows.append(("/_/downloads/en/latest/pdf/", 301, "/en/rolling/vyos-documentation.pdf"))
+ return rows
+
+
+class _NoRedirect(urllib.request.HTTPRedirectHandler):
+ """The parity checker must SEE 301s, not follow them (alias assertions)."""
+
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401
+ return None
+
+
+_OPENER = urllib.request.build_opener(_NoRedirect)
+
+# Overridable in tests (monkeypatched to "http") so fetch() can be exercised end-to-end
+# against a real local http.server instead of requiring TLS for a unit test.
+_SCHEME = "https"
+
+
+# The port each scheme already implies, so `host` and `host:443` are not two origins.
+_DEFAULT_PORTS = {"https": 443, "http": 80}
+
+
+def _authority(value: str) -> tuple[str, str | None, int | None]:
+ """The normalized ORIGIN of a full URL or of a bare `host[:port]` argument.
+
+ An origin is scheme + host + port, and all three are returned: a credential scoped to an
+ https host must not match a plaintext http URL. Dropping the scheme made
+ `Access("p.invalid", ...)` apply to `http://p.invalid/`, so the ONE choke point that
+ decides whether to attach the service token would have attached it to a cleartext
+ request. Nothing constructs such a URL today β€” every URL in this module is built from
+ _SCHEME, so a single run is single-scheme β€” but the scope of a credential should not
+ depend on that staying true.
+
+ Two spellings of one origin still have to compare equal, because the two sides of this
+ comparison come from different places: one is a URL this module built, the other is
+ whatever an operator typed after --probe-host. Comparing (hostname, port) verbatim made
+ `p.invalid` and `p.invalid:443` distinct, so spelling the default port out cost the
+ credential its own scope β€” post-cutover, where --sitemap-host and --probe-host name the
+ same Access-gated host, that silently 403'd every sitemap fetch and the sweep then
+ reported an empty corpus as a pass. Normalized here:
+
+ * case β€” `scheme` and `hostname` are already lowercased by urlsplit; kept explicit for
+ the reader.
+ * the root label's trailing dot β€” `p.invalid.` names the same host as `p.invalid`.
+ * the scheme's default port β†’ None, so `:443` under https (or `:80` under http) is not
+ a separate authority. Folded against the origin's OWN scheme, so http `:80` and
+ https `:443` stay the distinct origins they are.
+ * a bare argument carries no scheme, so it is read under _SCHEME β€” the scheme every
+ URL in this module is built with.
+
+ Deliberately NOT normalized: IDN/punycode equivalence (`ΓΌnΓ―code.example` against its
+ `xn--` form). Both hosts here are ASCII literals passed by CI, idna encoding carries its
+ own failure modes, and the safe direction for a credential-scoping test is to leave a
+ Unicode spelling not matching its punycode one rather than to guess an equivalence.
+ """
+ parts = urllib.parse.urlsplit(value if "://" in value else f"//{value}")
+ scheme = (parts.scheme or _SCHEME).lower()
+ host = parts.hostname.lower() if parts.hostname else None
+ if host and host.endswith("."):
+ host = host[:-1]
+ port = parts.port
+ if port is not None and port == _DEFAULT_PORTS.get(scheme):
+ port = None
+ return scheme, host, port
+
+
+@dataclasses.dataclass(frozen=True)
+class Access:
+ """A CF Access service token BOUND TO THE ONE HOST it may be presented to.
+
+ The binding is the point. This run talks to two hosts that are not the same party:
+ --probe-host is our Access-gated canary, while --sitemap-host is (pre-cutover)
+ docs.vyos.io, still served by ReadTheDocs. Credentials modelled as a bare
+ (id, secret) tuple carry no notion of destination, so a single `if access:` test in
+ the request builder sent our service token to BOTH β€” handing it to a third party on
+ every nightly sitemap fetch. Pairing the secret with its host makes the destination
+ check part of the credential rather than a rule each call site has to remember.
+ """
+
+ host: str
+ client_id: str
+ # repr=False: the default dataclass repr renders every field, so a failed assertion, a
+ # debug print or any exception that interpolates an Access would put the service token
+ # verbatim into CI logs β€” which are durable and, for this repo, world-readable. The id
+ # stays: it names WHICH token without being the credential, and losing it would make a
+ # scoping failure much harder to read. Secret is fetched via the attribute, never shown.
+ client_secret: str = dataclasses.field(repr=False)
+
+ def applies_to(self, url: str) -> bool:
+ """True only for a URL whose ORIGIN is this credential's host (see _authority)."""
+ return _authority(url) == _authority(self.host)
+
+
+def build_request(url: str, access: Access | None,
+ method: str = "HEAD") -> urllib.request.Request:
+ """The ONE place that attaches CF Access credentials to a request.
+
+ Every outbound request in this module goes through here, and the attach decision is
+ made PER DESTINATION, never per run. Two failure modes meet at this function and only
+ a host-scoped single choke point closes both:
+
+ * Credential leak. The sitemap host and the probe host are different parties
+ pre-cutover; an unscoped `if access:` mailed our service token to ReadTheDocs
+ once a night. `Access.applies_to()` makes that structurally impossible.
+ * Split-brain. The sitemap fetch used to build its own bare Request, so pointing
+ --sitemap-host at the Access-gated canary 403'd every sitemap while the probe
+ requests worked. Post-cutover both flags name the same host, and because the
+ scoping test is on the URL rather than on which caller asked, that configuration
+ still gets credentialed sitemap fetches with no extra wiring.
+ """
+ req = urllib.request.Request(url, method=method)
+ if access is not None and access.applies_to(url):
+ req.add_header("CF-Access-Client-Id", access.client_id)
+ req.add_header("CF-Access-Client-Secret", access.client_secret)
+ return req
+
+
+def fetch(host: str, path: str, access: Access | None, method: str = "HEAD"):
+ req = build_request(f"{_SCHEME}://{host}{path}", access, method)
+ try:
+ with _OPENER.open(req, timeout=30) as r:
+ return r.status, r.headers.get("Location")
+ except urllib.error.HTTPError as e: # 3xx land here with the no-redirect handler
+ return e.code, e.headers.get("Location")
+ except Exception: # noqa: BLE001 β€” DNS blip/timeout fails THIS probe, not the run
+ return 0, None
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--sitemap-host", required=True)
+ ap.add_argument("--probe-host", required=True)
+ ap.add_argument("--slugs", default=DEFAULT_SLUGS)
+ ap.add_argument("--report", type=Path, default=Path("parity-report.json"))
+ a = ap.parse_args()
+ # CF Access service-token credentials are read ONLY from the environment. They were
+ # also accepted as --access-id/--access-secret flags; that is removed rather than
+ # merely discouraged, because a value passed in argv is readable from the process table
+ # for the lifetime of the process and is captured verbatim by `set -x` traces, crash
+ # dumps and CI process listings. No call site used the flags (both workflows export the
+ # env vars), so there is nothing to migrate and no ergonomic loss worth the exposure.
+ # Access itself stays OPTIONAL: the sitemap host may be a public origin needing no token.
+ access_id = os.environ.get("CF_ACCESS_CLIENT_ID", "")
+ access_secret = os.environ.get("CF_ACCESS_CLIENT_SECRET", "")
+ if bool(access_id) != bool(access_secret):
+ # Half a service token is never usable β€” every probe would 403 and the run would
+ # report a wholly misleading "parity broken". Names only, never the values.
+ print("CF Access needs BOTH an id and a secret, or neither "
+ "(CF_ACCESS_CLIENT_ID, CF_ACCESS_CLIENT_SECRET)", file=sys.stderr)
+ return 2
+ # Bound to the PROBE host, and to nothing else. --probe-host is the host we own and
+ # gate with Access; --sitemap-host is whatever currently publishes the truth sitemaps,
+ # which pre-cutover is ReadTheDocs. Should the two flags name the same host β€” the
+ # post-cutover configuration β€” build_request() credentials the sitemap fetch too,
+ # because the test is on the destination and not on the call site.
+ access = Access(a.probe_host, access_id, access_secret) if access_id else None
+ failures: list[dict] = []
+ checked = 0
+
+ for slug in a.slugs.split(","):
+ # ONE request per sitemap. This used to probe the status with fetch() and then fetch
+ # the whole document a second time β€” two full GETs of a multi-thousand-URL sitemap per
+ # slug β€” and the body fetch hard-coded "https://", so the _SCHEME override (the hook
+ # the tests use to drive this path against a local plain-HTTP server) was ignored.
+ # Two things the single-call rewrite must NOT lose:
+ # 1. The discarded pre-check asserted status == 200 exactly. _OPENER raises
+ # HTTPError for non-2xx (3xx included β€” it refuses to follow redirects), but it
+ # RETURNS normally for any other 2xx, so a sitemap answering 204/206 would yield
+ # an empty corpus and the gate would pass having probed nothing. The explicit
+ # status check below restores that strictness.
+ # 2. CF Access credentials WHEN β€” and only when β€” the sitemap host is the host the
+ # token belongs to. A bare Request here 403'd a --sitemap-host pointed at the
+ # Access-gated canary; an unconditionally credentialed one posted the token to
+ # ReadTheDocs. build_request() decides per destination and settles both.
+ try:
+ with _OPENER.open(build_request(
+ f"{_SCHEME}://{a.sitemap_host}/en/{slug}/sitemap.xml", access, "GET"),
+ timeout=30) as r:
+ if r.status != 200:
+ failures.append({"path": f"/en/{slug}/sitemap.xml",
+ "reason": f"sitemap status {r.status}"})
+ continue
+ urls = urls_from_sitemap(r.read().decode())
+ except Exception as e: # noqa: BLE001 β€” record per-slug, keep sweeping; report ALWAYS written
+ failures.append({"path": f"/en/{slug}/sitemap.xml",
+ "reason": f"sitemap fetch error: {e}"})
+ continue
+ for path in urls:
+ checked += 1
+ st, _ = fetch(a.probe_host, path, access)
+ if st != 200:
+ failures.append({"path": path, "reason": f"status {st}"})
+
+ for path, want_status, want_loc in alias_corpus():
+ checked += 1
+ st, loc = fetch(a.probe_host, path, access)
+ if st != want_status or (loc or "") != want_loc:
+ failures.append({"path": path, "reason": f"got {st} β†’ {loc}, want {want_status} β†’ {want_loc}"})
+
+ a.report.write_text(json.dumps({"checked": checked, "failures": failures}, indent=2))
+ for f in failures:
+ print(f"PARITY-FAIL {f['path']}: {f['reason']}", file=sys.stderr)
+ print(f"checked={checked} failures={len(failures)}")
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())