summaryrefslogtreecommitdiff
path: root/scripts/docs_gates/smoke.py
blob: 770e93e365eb87e060851329055b68b1dda39833 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
"""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 time
import urllib.request

APEX_PATHS = ["/versions.json", "/healthz", "/robots.txt", "/sitemap.xml"]
SEARCH_MOUNT_MARKER = 'id="vyos-search"'

# Explicit UA so the gate never depends on a Cloudflare edge exemption for the default
# Python-urllib UA. The Browser Integrity Check blocked that UA until a skip rule was added;
# the gate must not silently rely on that rule surviving.
USER_AGENT = "vyos-docs-smoke/1.0 (+https://github.com/vyos/vyos-documentation)"

# Round-based retry (module-level so tests can shrink them). A freshly-deployed worker version
# can lose a propagation race: for a few minutes a probe may be served by the PREVIOUS version
# (wrong status / stale X-Docs-Build). Rather than fail-fast, each round re-probes ONLY the
# still-failing probes — this preserves the full per-probe failure enumeration (diagnostic
# value) while adding at most (MAX_ROUNDS - 1) inter-round sleeps. Envelope widened to 5 rounds
# x 30s after an observed propagation wave outlasted 3 rounds x 20s (a path still stale at
# round 3): 4 x 30s = 2 min now covers the observed 1-2+ min waves. The green path still costs
# zero extra time (no retries), and DEADLINE_SECONDS=480 still bounds the worst case.
MAX_ROUNDS = 5
RETRY_SLEEP_SECONDS = 30
DEADLINE_SECONDS = 480
# Per-socket-op timeout (connect + read), capped down to the remaining deadline budget on each
# probe so a probe that starts late cannot overshoot DEADLINE_SECONDS. Pages are small, so this
# socket-op timeout also bounds body reads adequately — no separate body-read deadline is needed.
PROBE_TIMEOUT_SECONDS = 30


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]:
    # `critical` may itself list "index.html" (it does in critical-pages.txt); drop it so the
    # index page is probed exactly once — as plan[0], the sole search-mount probe below.
    critical = [rel for rel in critical if rel != "index.html"]
    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/<slug>/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 _probe_once(host: str, probe: Probe, expect_sha: str, access_id: str, access_secret: str,
                timeout: float = PROBE_TIMEOUT_SECONDS,
                ) -> tuple[bool, int | None, str | None, str | None]:
    """One probe attempt. Returns (ok, status, docs_build, detail). `detail` names the failed
    check(s) — "status" / "docs-build" / "apex-build" / "search-mount" joined by "+", or the
    transport error text — and is None when ok. `timeout` is the per-socket-op deadline (connect
    + read); run() caps it to the remaining budget so a late probe can't overshoot the overall
    deadline. ANY exception in the open OR body-read path (including a transport error DURING
    HTTPError.read()) is contained and yields (False, None, None, <error text>): a retryable
    failure, never a traceback. The HTTPError response stream is always closed — it owns a
    socket, so a bare e.read() would leak it."""
    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)
    req.add_header("User-Agent", USER_AGENT)
    try:
        try:
            with _OPENER.open(req, timeout=timeout) as resp:
                status, headers, body = resp.status, resp.headers, resp.read()
        except urllib.error.HTTPError as e:  # non-2xx still carries headers/body
            with e:  # HTTPError is file-like and owns the response socket — always close it
                status, headers, body = e.code, e.headers, e.read()
    except Exception as e:  # noqa: BLE001 — open OR read failure → retryable probe result
        return False, None, None, str(e)
    reasons: list[str] = []
    if status != probe.expect_status:
        reasons.append("status")
    if probe.assert_docs_build and not docs_build_ok(headers.get("X-Docs-Build"), expect_sha):
        reasons.append("docs-build")
    if probe.assert_apex_build and not headers.get("X-Apex-Build"):
        reasons.append("apex-build")
    if probe.assert_search_mount and not search_mount_present(
            body.decode("utf-8", errors="replace")):
        reasons.append("search-mount")
    detail = "+".join(reasons) if reasons else None
    return not reasons, status, headers.get("X-Docs-Build"), detail


def run(host: str, slug: str, expect_sha: str, access_id: str, access_secret: str,
        pdf: str | None, critical: list[str]) -> int:
    """Probe the whole plan, then re-probe ONLY the still-failing probes each round (up to
    MAX_ROUNDS, one RETRY_SLEEP_SECONDS between rounds). A probe passing in ANY round passes;
    a single propagation blip served by the previous worker version cannot fail the gate.
    DEADLINE_SECONDS bounds total wall-clock — on breach, unresolved probes count as failed."""
    plan = probe_plan(slug, pdf, critical)
    start = time.monotonic()
    deadline = start + DEADLINE_SECONDS      # absolute — a hard upper bound on total wall-clock
    pending = list(plan)                     # probes not yet passed
    detail_by_path: dict[str, str] = {}      # last failure detail per path, for logging
    deadline_hit = False

    def _remaining() -> float:
        return deadline - time.monotonic()

    for round_num in range(1, MAX_ROUNDS + 1):
        if not pending:
            break
        still_failing: list[Probe] = []
        unprobed: list[Probe] = []
        for i, probe in enumerate(pending):
            remaining = _remaining()         # checked before each probe
            if remaining < 1:                # < 1s budget: don't start a probe that could overshoot
                deadline_hit = True
                unprobed = pending[i:]       # not reached this round → still unresolved
                break
            ok, status, docs_build, detail = _probe_once(
                host, probe, expect_sha, access_id, access_secret,
                timeout=min(PROBE_TIMEOUT_SECONDS, max(1, remaining)))
            if ok:
                continue
            still_failing.append(probe)
            detail_by_path[probe.path] = (
                f"status={status} docs-build={docs_build} detail={detail}")
        pending = still_failing + unprobed
        if deadline_hit or not pending or round_num == MAX_ROUNDS:
            break
        remaining = _remaining()             # checked before the inter-round sleep
        if remaining <= 0:                   # no budget left → deadline path (skip the sleep)
            deadline_hit = True
            break
        for probe in pending:
            print(f"SMOKE-RETRY {probe.path}: round {round_num} "
                  f"{detail_by_path[probe.path]}", file=sys.stderr)
        time.sleep(min(RETRY_SLEEP_SECONDS, remaining))   # never sleep past the deadline

    if deadline_hit:
        print("SMOKE-DEADLINE: overall deadline reached — remaining probes counted as failed",
              file=sys.stderr)
    for probe in pending:
        print(f"SMOKE-FAIL {probe.path}: {detail_by_path.get(probe.path, 'unresolved')}",
              file=sys.stderr)
    failures = len(pending)
    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())