Skip to content

Commit a670189

Browse files
committed
TUN-10962: Add precheck diag endpoint
Add /diag/precheck endpoint to fetch precheck results. Our component tests currently read this through logs, but it is be better to use a new endpoint for a more reliable source.
1 parent bf4f502 commit a670189

6 files changed

Lines changed: 205 additions & 100 deletions

File tree

‎cmd/cloudflared/tunnel/cmd.go‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -420,12 +420,16 @@ func StartServer(
420420
return err
421421
}
422422
connectorID := tunnelConfig.ClientConfig.ConnectorID
423+
precheckReports := diagnostic.NewPrecheckReportStore()
423424

424425
// Run connectivity pre-checks for cloudflared. This runs in a separate
425426
// goroutine, as we want to keep initializing cloudflared while prechecks
426427
// are running. Prechecks are controlled via DNS flag for remote kill-switch capability.
427428
if !tunnelConfig.ClientConfig.ConnectionFeaturesSnapshot().SkipPrechecks && !c.Bool(cfdflags.NoPrechecks) {
428-
go runPrechecks(c, log, tunnelConfig.Region, tunnelConfig.ClientConfig.ConnectionFeaturesSnapshot().PostQuantum)
429+
go func() {
430+
report := runPrechecks(c, log, tunnelConfig.Region, tunnelConfig.ClientConfig.ConnectionFeaturesSnapshot().PostQuantum)
431+
precheckReports.Set(report)
432+
}()
429433
}
430434

431435
// Disable ICMP packet routing for quick tunnels
@@ -502,6 +506,7 @@ func StartServer(
502506
tracker,
503507
cliFlags,
504508
sources,
509+
diagnostic.WithPrecheckReportStore(precheckReports),
505510
)
506511
metricsConfig := metrics.Config{
507512
ReadyServer: readinessServer,
@@ -530,7 +535,7 @@ func StartServer(
530535

531536
// runPrechecks executes connectivity pre-checks and logs the results.
532537
// Pre-checks are diagnostic only and do not gate tunnel startup.
533-
func runPrechecks(c *cli.Context, log *zerolog.Logger, region string, pqMode features.PostQuantumMode) {
538+
func runPrechecks(c *cli.Context, log *zerolog.Logger, region string, pqMode features.PostQuantumMode) prechecks.Report {
534539
ipVersion := allregions.Auto
535540
if ipVersionStr := c.String(cfdflags.EdgeIpVersion); ipVersionStr != "" {
536541
parsedVersion, err := parseConfigIPVersion(ipVersionStr)
@@ -562,6 +567,7 @@ func runPrechecks(c *cli.Context, log *zerolog.Logger, region string, pqMode fea
562567

563568
// Also log structured results for log aggregation
564569
report.LogEvent(log)
570+
return report
565571
}
566572

567573
func waitToShutdown(wg *sync.WaitGroup,

‎component-tests/test_prechecks.py‎

Lines changed: 42 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
These tests verify the end-to-end behavior of cloudflared pre-checks:
88
- that the human-readable table written to the log output has the correct
99
structure and content,
10-
- that structured JSON log lines are emitted with the expected fields, and
10+
- that the diagnostic endpoint exposes the completed report, and
1111
- that running the `diag` subcommand against a live tunnel instance produces a
1212
zip archive that contains prechecks.json.
1313
@@ -30,11 +30,10 @@
3030
The pre-checks table is emitted via cliutil.LogTable, which wraps the content
3131
in an ASCII box and logs each line at Info level through zerolog. zerolog
3232
writes to stderr, which the test harness merges into stdout (stderr=STDOUT in
33-
Popen). We poll a --logfile for the "precheck complete" sentinel before
34-
leaving the `with` block, ensuring the goroutine has finished. We then call
35-
cfd.terminate(). After the `with` block exits, the process is dead and all
36-
output has been captured by CloudflaredProcess's background reader thread. We
37-
read the accumulated lines from cfd.stdout_lines.
33+
Popen). We poll /diag/precheck before leaving the `with` block, ensuring the
34+
goroutine has finished. We then call cfd.terminate(). After the `with` block
35+
exits, the process is dead and all output has been captured by
36+
CloudflaredProcess's background reader thread.
3837
3938
Box format (cliutil.asciiBox with padding=2, title="CONNECTIVITY PRE-CHECKS"):
4039
+----...----+
@@ -52,6 +51,8 @@
5251
import time
5352
import zipfile as zipfilemod
5453

54+
import requests
55+
5556
from constants import METRICS_PORT
5657
from util import LOGGER, start_cloudflared, wait_tunnel_ready
5758

@@ -106,10 +107,6 @@
106107
SUMMARY_HEALTHY = "SUMMARY: Environment is healthy. cloudflared will use 'quic' as primary protocol."
107108
SUMMARY_CRITICAL = "SUMMARY: Environment has critical failures. cloudflared may not be able to establish a tunnel."
108109

109-
# structured log constants (result.go)
110-
111-
LOG_MSG_PRECHECK = "precheck"
112-
LOG_MSG_PRECHECK_COMPLETE = "precheck complete"
113110
STATUS_PASS_LOG = "pass"
114111

115112
UNREACHABLE_EDGE = "192.0.2.1:7844"
@@ -120,46 +117,21 @@
120117

121118
# ---------- helpers ----------
122119

123-
def _poll_log_file_for_precheck_complete(log_file: str, timeout: float) -> list[dict]:
124-
"""
125-
Poll a JSON log file until a 'precheck complete' line appears or timeout
126-
expires. Returns all precheck-related log lines found.
127-
128-
cloudflared's --logfile writes one JSON object per line. Polling keeps
129-
the test fast on healthy networks and still tolerates slow CI hosts.
130-
131-
We re-read from the beginning of the file on every poll because the file
132-
is append-only, small, and tracking a byte offset would add complexity with
133-
no meaningful performance benefit for a ~15 s total window.
134-
"""
120+
def _poll_precheck_endpoint(timeout: float) -> dict:
121+
"""Poll /diag/precheck until a completed report is available."""
122+
url = f"http://localhost:{METRICS_PORT}/diag/precheck"
135123
deadline = time.monotonic() + timeout
136124
while time.monotonic() < deadline:
137-
lines = _read_precheck_log_lines_from_file(log_file)
138-
if any(l.get("message") == LOG_MSG_PRECHECK_COMPLETE for l in lines):
139-
return lines
125+
try:
126+
response = requests.get(url, timeout=1)
127+
if response.status_code == 200:
128+
return response.json()
129+
assert response.status_code == 204, \
130+
f"Expected {url} to return 200 or 204, got {response.status_code}"
131+
except requests.exceptions.RequestException:
132+
pass
140133
time.sleep(PRECHECK_POLL_INTERVAL_SECS)
141-
return _read_precheck_log_lines_from_file(log_file)
142-
143-
144-
def _read_precheck_log_lines_from_file(log_file: str) -> list[dict]:
145-
"""Parse all precheck-related JSON log lines from a --logfile path."""
146-
result = []
147-
try:
148-
with open(log_file, "r") as f:
149-
for raw_line in f:
150-
raw_line = raw_line.strip()
151-
if not raw_line:
152-
continue
153-
try:
154-
obj = json.loads(raw_line)
155-
except json.JSONDecodeError:
156-
continue
157-
msg = obj.get("message") or obj.get("msg", "")
158-
if msg in (LOG_MSG_PRECHECK, LOG_MSG_PRECHECK_COMPLETE):
159-
result.append(obj)
160-
except FileNotFoundError:
161-
pass
162-
return result
134+
raise AssertionError(f"Timed out waiting for a completed precheck report from {url}")
163135

164136

165137
# stdout table parse
@@ -235,25 +207,21 @@ def _rows_for(rows: list[TableRow], component: str) -> list[TableRow]:
235207

236208
# log assertions
237209

238-
def _assert_precheck_summary_log(
239-
log_lines: list[dict],
210+
def _assert_precheck_report(
211+
report: dict,
240212
*,
241213
hard_fail: bool,
242214
suggested_protocol: str | None = None,
243215
):
244-
"""Assert the 'precheck complete' summary log line has the expected fields."""
245-
summary_lines = [l for l in log_lines if l.get("message") == LOG_MSG_PRECHECK_COMPLETE]
246-
assert len(summary_lines) == 1, \
247-
f"Expected exactly one '{LOG_MSG_PRECHECK_COMPLETE}' log line; got {summary_lines}"
248-
summary = summary_lines[0]
249-
250-
assert summary.get("hard_fail") is hard_fail, \
251-
f"Expected hard_fail={hard_fail} in summary log: {summary}"
216+
"""Assert the diagnostic report has the expected summary fields."""
217+
assert report.get("run_id"), f"Expected a run_id in precheck report: {report}"
218+
assert report.get("hard_fail") is hard_fail, \
219+
f"Expected hard_fail={hard_fail} in precheck report: {report}"
252220

253221
if suggested_protocol is not None:
254-
assert summary.get("suggested_protocol") == suggested_protocol, \
222+
assert report.get("suggested_protocol") == suggested_protocol, \
255223
(f"Expected suggested_protocol={suggested_protocol!r}; "
256-
f"got {summary.get('suggested_protocol')!r}")
224+
f"got {report.get('suggested_protocol')!r}")
257225

258226

259227
# ---------- Tests ----------
@@ -265,12 +233,11 @@ class TestPrechecksHappyPath:
265233
- every row's component, target, status, and details
266234
- no ERROR/WARNING action lines
267235
- the exact summary line
268-
- the structured log summary (hard_fail=false, suggested_protocol=quic)
236+
- the diagnostic report summary (hard_fail=false, suggested_protocol=quic)
269237
"""
270238

271239
def test_prechecks_pass_on_healthy_connection(self, tmp_path, component_tests_config):
272-
log_file = str(tmp_path / "cloudflared.log")
273-
config = component_tests_config({"logfile": log_file})
240+
config = component_tests_config({})
274241

275242
with start_cloudflared(
276243
tmp_path,
@@ -281,10 +248,7 @@ def test_prechecks_pass_on_healthy_connection(self, tmp_path, component_tests_co
281248
capture_output=True,
282249
) as cfd:
283250
wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1)
284-
# Poll the log file for the sentinel before signalling the process.
285-
log_lines = _poll_log_file_for_precheck_complete(
286-
log_file, timeout=PRECHECK_POLL_TIMEOUT_SECS
287-
)
251+
report = _poll_precheck_endpoint(timeout=PRECHECK_POLL_TIMEOUT_SECS)
288252
# Signal shutdown.
289253
cfd.terminate()
290254

@@ -293,7 +257,7 @@ def test_prechecks_pass_on_healthy_connection(self, tmp_path, component_tests_co
293257
stdout = b"".join(cfd.stdout_lines).decode(errors="replace")
294258

295259
LOGGER.debug(f"[happy-path] stdout:\n{stdout}")
296-
LOGGER.debug(f"[happy-path] log_lines:\n{log_lines}")
260+
LOGGER.debug(f"[happy-path] precheck report:\n{report}")
297261

298262
# Strip zerolog console prefixes so pattern matching works on raw messages.
299263
messages = "\n".join(_strip_log_prefix(l) for l in stdout.splitlines())
@@ -351,14 +315,12 @@ def test_prechecks_pass_on_healthy_connection(self, tmp_path, component_tests_co
351315
assert SUMMARY_HEALTHY in messages, \
352316
f"Expected healthy summary;\ngot:\n{stdout}"
353317

354-
# ── structured log ───────────────────────────────────────────────────
355-
assert len(log_lines) > 0, \
356-
"Expected at least one structured precheck log line in log file"
357-
for line in log_lines:
358-
if line.get("message") == LOG_MSG_PRECHECK:
359-
assert line.get("status") == STATUS_PASS_LOG, \
360-
f"Expected status=pass in precheck log line: {line}"
361-
_assert_precheck_summary_log(log_lines, hard_fail=False, suggested_protocol="quic")
318+
# ── diagnostic report ────────────────────────────────────────────────
319+
assert len(report["results"]) == 7
320+
for result in report["results"]:
321+
assert result["status"] == STATUS_PASS_LOG, \
322+
f"Expected status=pass in precheck report: {result}"
323+
_assert_precheck_report(report, hard_fail=False, suggested_protocol="quic")
362324

363325

364326
class TestPrechecksHardFail:
@@ -372,15 +334,14 @@ class TestPrechecksHardFail:
372334
- HTTP/2 row: FAIL with correct details + ERROR action
373335
- API row: PASS (api.cloudflare.com:443 is independently reachable)
374336
- the exact critical summary line
375-
- the structured log summary (hard_fail=true)
337+
- the diagnostic report summary (hard_fail=true)
376338
377339
This test does NOT call wait_tunnel_ready because the tunnel will not
378340
connect to the unreachable address.
379341
"""
380342

381343
def test_prechecks_hard_fail_when_edge_unreachable(self, tmp_path, component_tests_config):
382-
log_file = str(tmp_path / "cloudflared.log")
383-
config = component_tests_config({"logfile": log_file})
344+
config = component_tests_config({})
384345

385346
with start_cloudflared(
386347
tmp_path,
@@ -394,15 +355,13 @@ def test_prechecks_hard_fail_when_edge_unreachable(self, tmp_path, component_tes
394355
new_process=True,
395356
capture_output=True,
396357
) as cfd:
397-
log_lines = _poll_log_file_for_precheck_complete(
398-
log_file, timeout=PRECHECK_POLL_TIMEOUT_SECS
399-
)
358+
report = _poll_precheck_endpoint(timeout=PRECHECK_POLL_TIMEOUT_SECS)
400359
cfd.terminate()
401360

402361
stdout = b"".join(cfd.stdout_lines).decode(errors="replace")
403362

404363
LOGGER.debug(f"[hard-fail] stdout:\n{stdout}")
405-
LOGGER.debug(f"[hard-fail] log_lines:\n{log_lines}")
364+
LOGGER.debug(f"[hard-fail] precheck report:\n{report}")
406365

407366
# Strip zerolog console prefixes so pattern matching works on raw messages.
408367
messages = "\n".join(_strip_log_prefix(l) for l in stdout.splitlines())
@@ -454,7 +413,7 @@ def test_prechecks_hard_fail_when_edge_unreachable(self, tmp_path, component_tes
454413
assert SUMMARY_CRITICAL in messages, \
455414
f"Expected critical summary;\ngot:\n{stdout}"
456415

457-
_assert_precheck_summary_log(log_lines, hard_fail=True, suggested_protocol=None)
416+
_assert_precheck_report(report, hard_fail=True, suggested_protocol=None)
458417

459418

460419
class TestPreChecksDiag:

‎diagnostic/consts.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const (
1818
cliConfigurationEndpoint = "/diag/configuration"
1919
tunnelStateEndpoint = "/diag/tunnel"
2020
systemInformationEndpoint = "/diag/system"
21+
precheckEndpoint = "/diag/precheck"
2122
memoryDumpEndpoint = "debug/pprof/heap"
2223
goroutineDumpEndpoint = "debug/pprof/goroutine"
2324
metricsEndpoint = "metrics"

0 commit comments

Comments
 (0)