From 21689ef59b0eb34b1a29eda739dc10d33d25b44a Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Fri, 10 Jul 2026 17:14:13 +0300 Subject: docs: Cloudflare Workers hosting pipeline (apex, content workers, CI, previews) (#2140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs-infra: scaffold Cloudflare workers workspace (versions.json v2, matrix, toolchain) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: record full Phase-0 plan decision in workers/PLAN.md ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: shared content worker โ€” asset serving, cache classes, X-Docs-Build, canary no-store ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: run worker script before assets; test fetch entrypoint ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: apex manifest loader + dispatch map + runtime binding guard (TDD) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: apex redirects (aliases, PDF, trailing-slash) + special paths (TDD) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: PDF redirect honors pdf:null and preserves query ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: apex UA gate โ€” allowlist-wins, log-only AI crawlers, empty block list at launch ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: apex router (pipeline ยง3.2), themed 404/503, /kb seam, env configs + congruence test ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: add missing-User-Agent regression test for apex UA gate ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: R2-streaming preview worker โ€” MIME map, noindex, no-store (TDD) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: preview 404 no-store + fetch handler tests ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: bootstrap script โ€” binding-target workers must exist before apex deploys ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: apex run_worker_first, lockfile for npm ci, PDF Location from manifest ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: derive html_baseurl from DOCS_VERSION_SLUG with RTD fallback (canonical gate prereq) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: version picker + status banner + language scaffold (vanilla JS, TDD pure core) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: picker preserves query+hash across switch; valid breadcrumb markup ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: Pagefind search wrapper with runtime base-path + preview prefix handling (TDD) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: pagefind wrapper โ€” asset-failure notice + UI stylesheet load ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: gate Pagefind searchbox to CF builds (RTD keeps stock search until cutover) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: deploy sanity gates โ€” limits, critical pages, count-delta, canonical (TDD) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: hermetic gate tests via fixture versions.json ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: docs-build workflow โ€” candidate/smoke/promote two-stage deploy + registry + rollback Two-stage CF Workers pipeline: build in pinned container, assemble artifact, sanity gates, deploy candidate, scoped pre-traffic smoke via canary apex, promote (rollback-id capture, hostname purge, registry upload), post-promote probe + auto-rollback. DOCS_CF_LIVE repo variable gates every docs.vyos.io production interaction pre-cutover. scripts/docs_gates/smoke.py adds one authorized check beyond the spec: the version's index.html probe asserts the #vyos-search mount div is present in the response body, guarding CI silently forgetting DOCS_VERSION_SLUG (which would otherwise ship stock RTD search without the Pagefind gate noticing). ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: build docs image in-workflow with buildx cache (v4.1 โ€” digest pin dropped) Plan v4.1 amendment: the ghcr.io digest-pinned image does not exist (workflow would hard-fail at the first docker step on every push). Replace the BUILD_IMAGE env placeholder with an in-workflow docker build from docker/Dockerfile via docker/setup-buildx-action@v3 + docker/build-push-action@v6 (context: docker/, load: true, tags: docs-build:local, GHA cache from/to). The checked-out commit is the pin; buildx GHA cache keeps repeat builds cheap. Sphinx-build step swaps to docs-build:local; inner script unchanged. ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: apex/preview deploy workflow โ€” canary auto, production behind environment approval * docs-infra: apex-deploy concurrency guard (per-ref, cancel-in-progress) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: fork-safe PR preview pipeline โ€” approval record, R2 prefixes, label consumption, cleanup * docs-infra: nightly preview sweep โ€” pipefail + per-prefix failure isolation ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: nightly canary QA โ€” per-entry sweep + URL-parity corpus vs RTD ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: parity sweep scoped to CF-built versions; transport-error resilience ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: one-off bootstrap workflow (binding targets โ€” runs once on this push) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: remove one-off bootstrap workflow (bootstrap complete) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: one-off canary apex + preview deploy (route targets for Task 3.6 step 2c) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: remove one-off canary deploy workflow (targets live) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: address Phase-0 CodeRabbit findings (canonical gate, error caching, registry pointer, validation) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: strengthen manifest tests (full dispatch iteration, mutation-free validate) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: address GitHub CodeRabbit review (pointer-after-probe, fail-closed sweeps, block-precedence UA gate, preview hardening) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: adversarial review fixes โ€” error no-store, probe retry, PR-list membership, preview dotted-segment ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: serve oversized legacy PDF from R2 via apex (spec ยง5 fallback) The 1.3 PDF (29.2 MiB) exceeds the 25 MiB static-asset cap and is absent from the legacy content Worker's build, so /_/downloads/en/1.3/pdf/ (and the picker's PDF link) 301'd into a dead-end 404 post-cutover. Add the R2 object fallback spec ยง5 already documented but never implemented: a DOCS_PDFS R2 bucket binding on the apex Worker, a manifest pdf_r2_key field (1.3 only), and a router step ahead of version dispatch that streams the object with its own cache class (canary/error still force no-store). ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: PDF R2 fallback honors Range + If-None-Match, preserves ETag ๐Ÿค– Generated by [robots](https://vyos.io) --- scripts/docs_gates/smoke.py | 117 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 scripts/docs_gates/smoke.py (limited to 'scripts/docs_gates/smoke.py') diff --git a/scripts/docs_gates/smoke.py b/scripts/docs_gates/smoke.py new file mode 100644 index 00000000..d25e9a1f --- /dev/null +++ b/scripts/docs_gates/smoke.py @@ -0,0 +1,117 @@ +"""Scoped pre-traffic smoke + post-promote probe (spec ยง7.1 steps 2/5). + +Probes ONE version's pages plus apex special paths through a host, presenting a +CF Access service token. Header contract (ยง3.3): content probes assert +X-Docs-Build == --expect-sha; apex probes assert X-Apex-Build presence only. + +Phase-2 obligation (authorized addition, not in the original spec text): the +version's index.html probe also asserts the response body carries the +`#vyos-search` mount div (docs/_templates/searchbox.html), guarding the +Pagefind gate's silent-degrade failure mode โ€” a build that forgot to set +DOCS_VERSION_SLUG would otherwise ship stock RTD search without CI noticing. +""" +from __future__ import annotations + +import argparse +import dataclasses +import json +import sys +import urllib.request + +APEX_PATHS = ["/versions.json", "/healthz", "/robots.txt", "/sitemap.xml"] +SEARCH_MOUNT_MARKER = 'id="vyos-search"' + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Probes assert an EXACT status per-path (200 or 404) โ€” following a 3xx would + silently swap the probed status for whatever the redirect target returns, + masking an accidental redirect where a direct 200/404 was expected. Mirrors + parity.py's _NoRedirect/_OPENER pattern.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401 + return None + + +_OPENER = urllib.request.build_opener(_NoRedirect) + + +@dataclasses.dataclass +class Probe: + path: str + expect_status: int + assert_docs_build: bool + assert_apex_build: bool + assert_search_mount: bool = False + + +def probe_plan(slug: str, pdf: str | None, critical: list[str]) -> list[Probe]: + plan = [Probe(f"/en/{slug}/{rel}", 200, True, False) for rel in ["index.html", *critical]] + plan.append(Probe(f"/en/{slug}/pagefind/pagefind.js", 200, True, False)) + if pdf: + plan.append(Probe(pdf, 200, True, False)) + plan.append(Probe(f"/en/{slug}/definitely-missing-page-xyz.html", 404, False, False)) + plan += [Probe(p, 200, False, True) for p in APEX_PATHS] + plan[0].assert_search_mount = True # plan[0] is always /en//index.html + return plan + + +def docs_build_ok(header_value: str | None, expect_sha: str) -> bool: + """SKIP sentinel (nightly sweep): header presence only; otherwise exact match.""" + if expect_sha == "SKIP": + return header_value is not None + return header_value == expect_sha + + +def search_mount_present(html: str) -> bool: + return SEARCH_MOUNT_MARKER in html + + +def run(host: str, slug: str, expect_sha: str, access_id: str, access_secret: str, + pdf: str | None, critical: list[str]) -> int: + failures = 0 + for probe in probe_plan(slug, pdf, critical): + req = urllib.request.Request(f"https://{host}{probe.path}", method="GET") + req.add_header("CF-Access-Client-Id", access_id) + req.add_header("CF-Access-Client-Secret", access_secret) + try: + with _OPENER.open(req, timeout=30) as resp: + status, headers, body = resp.status, resp.headers, resp.read() + except urllib.error.HTTPError as e: # non-2xx still carries headers + status, headers, body = e.code, e.headers, e.read() + except Exception as e: # noqa: BLE001 โ€” any transport error fails the probe + print(f"SMOKE-FAIL {probe.path}: {e}", file=sys.stderr) + failures += 1 + continue + ok = status == probe.expect_status + if probe.assert_docs_build and not docs_build_ok(headers.get("X-Docs-Build"), expect_sha): + ok = False + if probe.assert_apex_build and not headers.get("X-Apex-Build"): + ok = False + if probe.assert_search_mount and not search_mount_present( + body.decode("utf-8", errors="replace")): + ok = False + if not ok: + print(f"SMOKE-FAIL {probe.path}: status={status} " + f"docs-build={headers.get('X-Docs-Build')}", file=sys.stderr) + failures += 1 + print(json.dumps({"failures": failures})) + return 1 if failures else 0 + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--host", required=True) + ap.add_argument("--slug", required=True) + ap.add_argument("--expect-sha", required=True) + ap.add_argument("--access-id", required=True) + ap.add_argument("--access-secret", required=True) + ap.add_argument("--pdf", default=None) + ap.add_argument("--critical-list", default="scripts/docs_gates/critical-pages.txt") + a = ap.parse_args() + critical = [line.strip() for line in open(a.critical_list).read().splitlines() + if line.strip() and not line.startswith("#")] + return run(a.host, a.slug, a.expect_sha, a.access_id, a.access_secret, a.pdf, critical) + + +if __name__ == "__main__": + raise SystemExit(main()) -- cgit v1.2.3