summaryrefslogtreecommitdiff
path: root/scripts/docs_gates/parity.py
diff options
context:
space:
mode:
authorYuriy Andamasov <yuriy@vyos.io>2026-08-21 23:43:26 +0300
committerYuriy Andamasov <yuriy@vyos.io>2026-08-21 23:43:26 +0300
commitbeec730d3743687482c6516dec8c15cc2bcea63b (patch)
treed0f2ab96c5b547b493fc517e42b95f37801ce48f /scripts/docs_gates/parity.py
parent0f69d0846fef313f23c84d7dff8b5ae8e24ec04c (diff)
downloadvyos-documentation-claude/cf-port-circinus.tar.gz
vyos-documentation-claude/cf-port-circinus.zip
ci: IS-572: re-sync ported Cloudflare Workers pipeline files with rollingclaude/cf-port-circinus
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.py160
1 files changed, 146 insertions, 14 deletions
diff --git a/scripts/docs_gates/parity.py b/scripts/docs_gates/parity.py
index 08225f5a..c30536db 100644
--- a/scripts/docs_gates/parity.py
+++ b/scripts/docs_gates/parity.py
@@ -9,9 +9,12 @@ 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
@@ -58,11 +61,107 @@ _OPENER = urllib.request.build_opener(_NoRedirect)
_SCHEME = "https"
-def fetch(host: str, path: str, access: tuple[str, str] | None, method: str = "HEAD"):
- req = urllib.request.Request(f"{_SCHEME}://{host}{path}", method=method)
- if access:
- req.add_header("CF-Access-Client-Id", access[0])
- req.add_header("CF-Access-Client-Secret", access[1])
+# 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")
@@ -77,22 +176,55 @@ def main() -> int:
ap.add_argument("--sitemap-host", required=True)
ap.add_argument("--probe-host", required=True)
ap.add_argument("--slugs", default=DEFAULT_SLUGS)
- ap.add_argument("--access-id")
- ap.add_argument("--access-secret")
ap.add_argument("--report", type=Path, default=Path("parity-report.json"))
a = ap.parse_args()
- access = (a.access_id, a.access_secret) if a.access_id else None
+ # 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(","):
- status, _ = fetch(a.sitemap_host, f"/en/{slug}/sitemap.xml", None, "GET")
- if status != 200:
- failures.append({"path": f"/en/{slug}/sitemap.xml", "reason": f"sitemap {status}"})
- continue
+ # 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 urllib.request.urlopen(f"https://{a.sitemap_host}/en/{slug}/sitemap.xml",
- timeout=30) as r:
+ 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",