From 9f084e6800d109759070e9b18ea698c4f94b3e0e Mon Sep 17 00:00:00 2001 From: Miguel da Costa Martins Marcelino Date: Thu, 23 Apr 2026 19:04:06 +0000 Subject: [PATCH] TUN-10386: Add Table Renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The goals of this PR are twofold: ## **1. Introduce a new renderer to output to `stdout`** Implement the table renderer that will be used to report the results to stdout. The renderer should output something similar to this: ``` ─── CONNECTIVITY PRE-CHECKS ────────────────────────────────────────────────── COMPONENT TARGET STATUS DETAILS DNS Resolution region1.v2.argotunnel.com PASS Resolved successfully DNS Resolution region2.v2.argotunnel.com PASS Resolved successfully UDP Connectivity Port 7844 (QUIC) PASS Handshake successful TCP Connectivity Port 7844 (HTTP/2) PASS TLS handshake successful Cloudflare API api.cloudflare.com:443 PASS Reachable SUMMARY: Environment is healthy. cloudflared will use 'quic' as primary protocol. ────────────────────────────────────────────────────────────────────────────── ``` ## **2. Add a log-level renderer** Add support for structured logging to print the table results as logs. Below is an example of how logs should look like: ``` {"level":"info","run_id":"52828729-dfwd-45b3-w12f-727cbdb4cbd4", "component":"DNS Resolution","target":"region1.v2.argotunnel.com","status":"pass","details":"Resolved successfully","time":"2024-01-15T10:30:00Z","message":"precheck"} {"level":"info","run_id":"52828729-dfwd-45b3-w12f-727cbdb4cbd4", "component":"DNS Resolution","target":"region2.v2.argotunnel.com","status":"pass","details":"Resolved successfully","time":"2024-01-15T10:30:00Z","message":"precheck"} {"level":"info","run_id":"52828729-dfwd-45b3-w12f-727cbdb4cbd4", "component":"QUIC Connectivity","target":"Port 7844 (QUIC)","status":"pass","details":"Handshake successful","time":"2024-01-15T10:30:01Z","message":"precheck"} {"level":"info","run_id":"52828729-dfwd-45b3-w12f-727cbdb4cbd4", "component":"HTTP/2 Connectivity","target":"Port 7844 (HTTP/2)","status":"pass","details":"TLS handshake successful","time":"2024-01-15T10:30:01Z","message":"precheck"} {"level":"info","run_id":"52828729-dfwd-45b3-w12f-727cbdb4cbd4", "component":"Management API","target":"api.cloudflare.com:443","status":"pass","details":"Reachable","time":"2024-01-15T10:30:01Z","message":"precheck"} {"level":"info","run_id":"52828729-dfwd-45b3-w12f-727cbdb4cbd4", "hard_fail":false,"suggested_protocol":"quic","time":"2024-01-15T10:30:01Z","message":"precheck complete"} ```   --- prechecks/result.go | 218 ++++++++++++++++++++ prechecks/result_test.go | 418 +++++++++++++++++++++++++++++++++++++++ prechecks/types.go | 39 ++-- 3 files changed, 664 insertions(+), 11 deletions(-) create mode 100644 prechecks/result.go create mode 100644 prechecks/result_test.go diff --git a/prechecks/result.go b/prechecks/result.go new file mode 100644 index 00000000..991451f0 --- /dev/null +++ b/prechecks/result.go @@ -0,0 +1,218 @@ +package prechecks + +import ( + "bytes" + "fmt" + "strings" + "text/tabwriter" + + "github.com/rs/zerolog" +) + +const ( + // tableWidth is the total character width of the separator lines. + tableWidth = 80 + + // Status names. + passStatus = "PASS" + failStatus = "FAIL" + skipStatus = "SKIP" + unknownStatus = "UNKNOWN" + + // Section separators. + sectionChar = "-" + headerTitle = "CONNECTIVITY PRE-CHECKS" + + // Log message constants. + logMsgPrecheck = "precheck" + logMsgPrecheckComplete = "precheck complete" + + // Log field names. + logFieldRunID = "run_id" + logFieldComponent = "component" + logFieldTarget = "target" + logFieldStatus = "status" + logFieldDetails = "details" + logFieldHardFail = "hard_fail" + logFieldSuggestedProtocol = "suggested_protocol" + + sep = " " +) + +// statusLabel returns the display label for a given Status. +func (s Status) statusLabel() string { + switch s { + case Pass: + return passStatus + case Fail: + return failStatus + case Skip: + return skipStatus + default: + return unknownStatus + } +} + +// logString returns the lowercase string used in structured log fields. +func (s Status) logString() string { + return strings.ToLower(s.String()) +} + +// separator returns a full-width horizontal line. +func separator() string { + return strings.Repeat(sectionChar, tableWidth) +} + +// header returns the top section title line. +func header() string { + leftDashes := strings.Repeat(sectionChar, 3) + rightLen := tableWidth - len(leftDashes) - len(headerTitle) - len(sep)*2 + return leftDashes + sep + headerTitle + sep + strings.Repeat(sectionChar, rightLen) +} + +// renderTable uses text/tabwriter to format the results rows with +// automatically aligned columns, returning the rendered string. +func renderTable(results []CheckResult) string { + var buf bytes.Buffer + // minwidth=0, tabwidth=8, padding=2, padchar=' ', flags=0 + w := tabwriter.NewWriter(&buf, 0, 8, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "COMPONENT\tTARGET\tSTATUS\tDETAILS") + for _, r := range results { + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Component, r.Target, r.ProbeStatus.statusLabel(), r.Details) + } + _ = w.Flush() + return buf.String() +} + +// renderActions collects all non-empty Action strings from results and returns +// the formatted warning/error block that appears between the table and SUMMARY. +// A Fail result is rendered as ERROR when the report is a hard fail, and as +// WARNING otherwise (degraded but tunnel can still run). +func renderActions(r Report) string { + hardFail := r.hasHardFail() + var sb strings.Builder + for _, res := range r.Results { + if res.Action == "" || res.ProbeStatus != Fail { + continue + } + if hardFail { + _, _ = fmt.Fprintf(&sb, "ERROR: %s\n", res.Action) + } else { + _, _ = fmt.Fprintf(&sb, "WARNING: %s\n", res.Action) + } + } + return sb.String() +} + +// summaryLine builds the SUMMARY: line based on the Report state. +func summaryLine(r Report) string { + switch { + case r.hasHardFail(): + return "SUMMARY: Environment has critical failures. cloudflared may not be able to establish a tunnel." + case r.hasWarn(): + return fmt.Sprintf("SUMMARY: Environment ready with degraded transport. cloudflared will proceed using '%s'.", r.SuggestedProtocol) + default: + return fmt.Sprintf("SUMMARY: Environment is healthy. cloudflared will use '%s' as primary protocol.", r.SuggestedProtocol) + } +} + +// hasHardFail returns true when the environment cannot establish a tunnel: +// - Any DNS probe failed, OR +// - Both the QUIC and HTTP/2 transport probes failed. +// +// A single transport failing is not a hard fail — the other transport can be +// used as a fallback (degraded state, reported via hasWarn). +func (r Report) hasHardFail() bool { + var quicFail, http2Fail bool + for _, res := range r.Results { + if res.ProbeStatus != Fail { + continue + } + switch res.Type { + case ProbeTypeDNS: + return true + case ProbeTypeQUIC: + quicFail = true + case ProbeTypeHTTP2: + http2Fail = true + case ProbeTypeManagementAPI: + // Management API failure is not a hard fail + } + } + return quicFail && http2Fail +} + +// hasWarn returns true when the environment is degraded but functional: +// - Exactly one transport (QUIC or HTTP/2) failed, OR +// - The Management API is unreachable (auto-updates unavailable). +// +// Hard-fail conditions (DNS down, both transports blocked) take precedence +// and will cause hasWarn to return false. +func (r Report) hasWarn() bool { + if r.hasHardFail() { + return false + } + var quicFail, http2Fail, apiFail bool + for _, res := range r.Results { + if res.ProbeStatus != Fail { + continue + } + switch res.Type { + case ProbeTypeQUIC: + quicFail = true + case ProbeTypeHTTP2: + http2Fail = true + case ProbeTypeManagementAPI: + apiFail = true + case ProbeTypeDNS: + // DNS failures are only relevant for hard failures + } + } + return (quicFail != http2Fail) || apiFail +} + +// String renders the Report as a human-readable table suitable for os.Stdout. +func (r Report) String() string { + var sb strings.Builder + + sb.WriteString(header()) + sb.WriteString("\n") + + sb.WriteString(renderTable(r.Results)) + + actions := renderActions(r) + if actions != "" { + sb.WriteString(actions) + } + + sb.WriteString("\n") + sb.WriteString(summaryLine(r)) + sb.WriteString("\n") + + sb.WriteString(separator()) + sb.WriteString("\n") + + return sb.String() +} + +// LogEvent emits each CheckResult as a structured zerolog log line, followed by +// a final summary event. This is the JSON-logging equivalent of String(). +// Every line carries run_id so all results from a single invocation can be correlated. +func (r Report) LogEvent(logger *zerolog.Logger) { + runID := r.RunID.String() + for _, res := range r.Results { + logger.Info(). + Str(logFieldRunID, runID). + Str(logFieldComponent, res.Component). + Str(logFieldTarget, res.Target). + Str(logFieldStatus, res.ProbeStatus.logString()). + Str(logFieldDetails, res.Details). + Msg(logMsgPrecheck) + } + + logger.Info(). + Str(logFieldRunID, runID). + Bool(logFieldHardFail, r.hasHardFail()). + Str(logFieldSuggestedProtocol, r.SuggestedProtocol.String()). + Msg(logMsgPrecheckComplete) +} diff --git a/prechecks/result_test.go b/prechecks/result_test.go new file mode 100644 index 00000000..c0aae7ff --- /dev/null +++ b/prechecks/result_test.go @@ -0,0 +1,418 @@ +package prechecks + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cloudflare/cloudflared/connection" +) + +// fixedRunID is used in all fixtures so golden strings are deterministic. +var fixedRunID = uuid.MustParse("00000000-0000-0000-0000-000000000001") + +// Fixtures + +// allPassReport is the all-checks-pass scenario: QUIC is the suggested protocol. +func allPassReport() Report { + return Report{ + RunID: fixedRunID, + SuggestedProtocol: connection.QUIC, + Results: []CheckResult{ + {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region1.v2.argotunnel.com", ProbeStatus: Pass, Details: "Resolved successfully"}, + {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Pass, Details: "Resolved successfully"}, + {Type: ProbeTypeQUIC, Component: "UDP Connectivity", Target: "Port 7844 (QUIC)", ProbeStatus: Pass, Details: "Handshake successful"}, + {Type: ProbeTypeHTTP2, Component: "TCP Connectivity", Target: "Port 7844 (HTTP/2)", ProbeStatus: Pass, Details: "TLS handshake successful"}, + {Type: ProbeTypeManagementAPI, Component: "Cloudflare API", Target: "api.cloudflare.com:443", ProbeStatus: Pass, Details: "Reachable"}, + }, + } +} + +// quicBlockedReport is the degraded scenario: QUIC is blocked, HTTP/2 is the fallback. +// Only one transport failed so this is a warning, not a hard fail. +func quicBlockedReport() Report { + return Report{ + RunID: fixedRunID, + SuggestedProtocol: connection.HTTP2, + Results: []CheckResult{ + {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region1.v2.argotunnel.com", ProbeStatus: Pass, Details: "Resolved successfully"}, + {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Pass, Details: "Resolved successfully"}, + { + Type: ProbeTypeQUIC, + Component: "UDP Connectivity", + Target: "Port 7844 (QUIC)", + ProbeStatus: Fail, + Details: "Handshake failed", + Action: "Allow outbound QUIC on port 7844. cloudflared will use http2 in the meantime.", + }, + {Type: ProbeTypeHTTP2, Component: "TCP Connectivity", Target: "Port 7844 (HTTP/2)", ProbeStatus: Pass, Details: "TLS handshake successful"}, + {Type: ProbeTypeManagementAPI, Component: "Cloudflare API", Target: "api.cloudflare.com:443", ProbeStatus: Pass, Details: "Reachable"}, + }, + } +} + +// apiFailReport is the degraded scenario: all connectivity passes but the Cloudflare +// API is unreachable. The tunnel can still run; only automatic updates are unavailable. +func apiFailReport() Report { + return Report{ + RunID: fixedRunID, + SuggestedProtocol: connection.QUIC, + Results: []CheckResult{ + {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region1.v2.argotunnel.com", ProbeStatus: Pass, Details: "Resolved successfully"}, + {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Pass, Details: "Resolved successfully"}, + {Type: ProbeTypeQUIC, Component: "UDP Connectivity", Target: "Port 7844 (QUIC)", ProbeStatus: Pass, Details: "Handshake successful"}, + {Type: ProbeTypeHTTP2, Component: "TCP Connectivity", Target: "Port 7844 (HTTP/2)", ProbeStatus: Pass, Details: "TLS handshake successful"}, + { + Type: ProbeTypeManagementAPI, + Component: "Cloudflare API", + Target: "api.cloudflare.com:443", + ProbeStatus: Fail, + Details: "Connection refused", + Action: "cloudflared will still run, but automatic software updates are unavailable. Ensure port 443 TCP to api.cloudflare.com is open if you want auto-updates.", + }, + }, + } +} + +// bothTransportsBlockedReport is the hard-fail scenario: both QUIC and HTTP/2 are blocked. +func bothTransportsBlockedReport() Report { + return Report{ + RunID: fixedRunID, + SuggestedProtocol: connection.HTTP2, + Results: []CheckResult{ + {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region1.v2.argotunnel.com", ProbeStatus: Pass, Details: "Resolved successfully"}, + {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Pass, Details: "Resolved successfully"}, + { + Type: ProbeTypeQUIC, + Component: "UDP Connectivity", + Target: "Port 7844 (QUIC)", + ProbeStatus: Fail, + Details: "Handshake failed", + Action: "Allow outbound QUIC and/or TCP on port 7844 to the Cloudflare edge.", + }, + { + Type: ProbeTypeHTTP2, + Component: "TCP Connectivity", + Target: "Port 7844 (HTTP/2)", + ProbeStatus: Fail, + Details: "Blocked or unreachable", + }, + {Type: ProbeTypeManagementAPI, Component: "Cloudflare API", Target: "api.cloudflare.com:443", ProbeStatus: Pass, Details: "Reachable"}, + }, + } +} + +// dnsFailReport is the hard-fail scenario: DNS is unresolvable, transports are skipped. +func dnsFailReport() Report { + return Report{ + RunID: fixedRunID, + SuggestedProtocol: connection.HTTP2, + Results: []CheckResult{ + { + Type: ProbeTypeDNS, + Component: "DNS Resolution", + Target: "region1.v2.argotunnel.com", + ProbeStatus: Fail, + Details: "No addresses returned", + Action: "Ensure your DNS resolver can resolve 'region1.v2.argotunnel.com'. Run: dig A region1.v2.argotunnel.com @1.1.1.1. If that fails, contact your network administrator.", + }, + {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Fail, Details: "No addresses returned"}, + {Type: ProbeTypeQUIC, Component: "UDP Connectivity", Target: "Port 7844 (QUIC)", ProbeStatus: Skip, Details: "DNS prerequisite failed"}, + {Type: ProbeTypeHTTP2, Component: "TCP Connectivity", Target: "Port 7844 (HTTP/2)", ProbeStatus: Skip, Details: "DNS prerequisite failed"}, + {Type: ProbeTypeManagementAPI, Component: "Cloudflare API", Target: "api.cloudflare.com:443", ProbeStatus: Fail, Details: "Connection refused"}, + }, + } +} + +// String() / table renderer tests + +func TestString_AllPass(t *testing.T) { + t.Parallel() + want := "" + + "--- CONNECTIVITY PRE-CHECKS ----------------------------------------------------\n" + + "COMPONENT TARGET STATUS DETAILS\n" + + "DNS Resolution region1.v2.argotunnel.com PASS Resolved successfully\n" + + "DNS Resolution region2.v2.argotunnel.com PASS Resolved successfully\n" + + "UDP Connectivity Port 7844 (QUIC) PASS Handshake successful\n" + + "TCP Connectivity Port 7844 (HTTP/2) PASS TLS handshake successful\n" + + "Cloudflare API api.cloudflare.com:443 PASS Reachable\n" + + "\n" + + "SUMMARY: Environment is healthy. cloudflared will use 'quic' as primary protocol.\n" + + "--------------------------------------------------------------------------------\n" + assert.Equal(t, want, allPassReport().String()) +} + +func TestString_QuicBlocked(t *testing.T) { + t.Parallel() + want := "" + + "--- CONNECTIVITY PRE-CHECKS ----------------------------------------------------\n" + + "COMPONENT TARGET STATUS DETAILS\n" + + "DNS Resolution region1.v2.argotunnel.com PASS Resolved successfully\n" + + "DNS Resolution region2.v2.argotunnel.com PASS Resolved successfully\n" + + "UDP Connectivity Port 7844 (QUIC) FAIL Handshake failed\n" + + "TCP Connectivity Port 7844 (HTTP/2) PASS TLS handshake successful\n" + + "Cloudflare API api.cloudflare.com:443 PASS Reachable\n" + + "WARNING: Allow outbound QUIC on port 7844. cloudflared will use http2 in the meantime.\n" + + "\n" + + "SUMMARY: Environment ready with degraded transport. cloudflared will proceed using 'http2'.\n" + + "--------------------------------------------------------------------------------\n" + assert.Equal(t, want, quicBlockedReport().String()) +} + +func TestString_APIFail(t *testing.T) { + t.Parallel() + want := "" + + "--- CONNECTIVITY PRE-CHECKS ----------------------------------------------------\n" + + "COMPONENT TARGET STATUS DETAILS\n" + + "DNS Resolution region1.v2.argotunnel.com PASS Resolved successfully\n" + + "DNS Resolution region2.v2.argotunnel.com PASS Resolved successfully\n" + + "UDP Connectivity Port 7844 (QUIC) PASS Handshake successful\n" + + "TCP Connectivity Port 7844 (HTTP/2) PASS TLS handshake successful\n" + + "Cloudflare API api.cloudflare.com:443 FAIL Connection refused\n" + + "WARNING: cloudflared will still run, but automatic software updates are unavailable. Ensure port 443 TCP to api.cloudflare.com is open if you want auto-updates.\n" + + "\n" + + "SUMMARY: Environment ready with degraded transport. cloudflared will proceed using 'quic'.\n" + + "--------------------------------------------------------------------------------\n" + assert.Equal(t, want, apiFailReport().String()) +} + +func TestString_BothTransportsBlocked(t *testing.T) { + t.Parallel() + want := "" + + "--- CONNECTIVITY PRE-CHECKS ----------------------------------------------------\n" + + "COMPONENT TARGET STATUS DETAILS\n" + + "DNS Resolution region1.v2.argotunnel.com PASS Resolved successfully\n" + + "DNS Resolution region2.v2.argotunnel.com PASS Resolved successfully\n" + + "UDP Connectivity Port 7844 (QUIC) FAIL Handshake failed\n" + + "TCP Connectivity Port 7844 (HTTP/2) FAIL Blocked or unreachable\n" + + "Cloudflare API api.cloudflare.com:443 PASS Reachable\n" + + "ERROR: Allow outbound QUIC and/or TCP on port 7844 to the Cloudflare edge.\n" + + "\n" + + "SUMMARY: Environment has critical failures. cloudflared may not be able to establish a tunnel.\n" + + "--------------------------------------------------------------------------------\n" + assert.Equal(t, want, bothTransportsBlockedReport().String()) +} + +func TestString_DNSFail(t *testing.T) { + t.Parallel() + want := "" + + "--- CONNECTIVITY PRE-CHECKS ----------------------------------------------------\n" + + "COMPONENT TARGET STATUS DETAILS\n" + + "DNS Resolution region1.v2.argotunnel.com FAIL No addresses returned\n" + + "DNS Resolution region2.v2.argotunnel.com FAIL No addresses returned\n" + + "UDP Connectivity Port 7844 (QUIC) SKIP DNS prerequisite failed\n" + + "TCP Connectivity Port 7844 (HTTP/2) SKIP DNS prerequisite failed\n" + + "Cloudflare API api.cloudflare.com:443 FAIL Connection refused\n" + + "ERROR: Ensure your DNS resolver can resolve 'region1.v2.argotunnel.com'. Run: dig A region1.v2.argotunnel.com @1.1.1.1. If that fails, contact your network administrator.\n" + + "\n" + + "SUMMARY: Environment has critical failures. cloudflared may not be able to establish a tunnel.\n" + + "--------------------------------------------------------------------------------\n" + assert.Equal(t, want, dnsFailReport().String()) +} + +func TestString_EmptyResults(t *testing.T) { + t.Parallel() + r := Report{RunID: fixedRunID, SuggestedProtocol: connection.QUIC} + out := r.String() + // Must not panic and must still emit a valid skeleton. + assert.Contains(t, out, "CONNECTIVITY PRE-CHECKS") + assert.Contains(t, out, "SUMMARY:") + assert.Contains(t, out, separator()) +} + +// LogEvent() / structured log renderer tests + +// logEntry is a helper struct to unmarshal a single JSON log line emitted by LogEvent. +type logEntry struct { + Level string `json:"level"` + RunID string `json:"run_id"` + Component string `json:"component"` + Target string `json:"target"` + Status string `json:"status"` + Details string `json:"details"` + Message string `json:"message"` + HardFail *bool `json:"hard_fail"` + SuggestedProtocol string `json:"suggested_protocol"` +} + +// captureLogLines runs LogEvent against a buffer-backed zerolog logger and +// returns the parsed JSON entries, one per emitted line. +func captureLogLines(t *testing.T, r Report) []logEntry { + t.Helper() + var buf bytes.Buffer + logger := zerolog.New(&buf) + r.LogEvent(&logger) + + var entries []logEntry + for _, line := range strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") { + if line == "" { + continue + } + var e logEntry + require.NoError(t, json.Unmarshal([]byte(line), &e), "failed to parse log line: %s", line) + entries = append(entries, e) + } + return entries +} + +func TestLogEvent_AllPass(t *testing.T) { + t.Parallel() + r := allPassReport() + entries := captureLogLines(t, r) + + // One line per result plus one summary line. + require.Len(t, entries, len(r.Results)+1) + + // Each result line carries the right fields. + expected := []struct { + component string + target string + status string + details string + }{ + {"DNS Resolution", "region1.v2.argotunnel.com", "pass", "Resolved successfully"}, + {"DNS Resolution", "region2.v2.argotunnel.com", "pass", "Resolved successfully"}, + {"UDP Connectivity", "Port 7844 (QUIC)", "pass", "Handshake successful"}, + {"TCP Connectivity", "Port 7844 (HTTP/2)", "pass", "TLS handshake successful"}, + {"Cloudflare API", "api.cloudflare.com:443", "pass", "Reachable"}, + } + for i, exp := range expected { + e := entries[i] + assert.Equal(t, "info", e.Level, "entry %d: level", i) + assert.Equal(t, fixedRunID.String(), e.RunID, "entry %d: run_id", i) + assert.Equal(t, exp.component, e.Component, "entry %d: component", i) + assert.Equal(t, exp.target, e.Target, "entry %d: target", i) + assert.Equal(t, exp.status, e.Status, "entry %d: status", i) + assert.Equal(t, exp.details, e.Details, "entry %d: details", i) + assert.Equal(t, logMsgPrecheck, e.Message, "entry %d: message", i) + } + + // Summary line. + summary := entries[len(entries)-1] + assert.Equal(t, logMsgPrecheckComplete, summary.Message) + assert.Equal(t, fixedRunID.String(), summary.RunID) + require.NotNil(t, summary.HardFail) + assert.False(t, *summary.HardFail) + assert.Equal(t, "quic", summary.SuggestedProtocol) +} + +func TestLogEvent_QuicBlocked(t *testing.T) { + t.Parallel() + entries := captureLogLines(t, quicBlockedReport()) + + // QUIC row (index 2) must carry status=fail and the right details. + quic := entries[2] + assert.Equal(t, "fail", quic.Status) + assert.Equal(t, "UDP Connectivity", quic.Component) + assert.Equal(t, "Port 7844 (QUIC)", quic.Target) + assert.Equal(t, "Handshake failed", quic.Details) + assert.Equal(t, fixedRunID.String(), quic.RunID) + + // Summary: not a hard fail (HTTP/2 still works), protocol falls back to http2. + summary := entries[len(entries)-1] + require.NotNil(t, summary.HardFail) + assert.False(t, *summary.HardFail) + assert.Equal(t, "http2", summary.SuggestedProtocol) + assert.Equal(t, fixedRunID.String(), summary.RunID) +} + +func TestLogEvent_APIFail(t *testing.T) { + t.Parallel() + entries := captureLogLines(t, apiFailReport()) + + // API row (index 4) carries status=fail and the expected details. + api := entries[4] + assert.Equal(t, "fail", api.Status) + assert.Equal(t, "Cloudflare API", api.Component) + assert.Equal(t, "api.cloudflare.com:443", api.Target) + assert.Equal(t, "Connection refused", api.Details) + assert.Equal(t, fixedRunID.String(), api.RunID) + + // All transport rows pass. + assert.Equal(t, "pass", entries[2].Status) + assert.Equal(t, "pass", entries[3].Status) + + // Summary: not a hard fail, QUIC is still the suggested protocol. + summary := entries[len(entries)-1] + require.NotNil(t, summary.HardFail) + assert.False(t, *summary.HardFail) + assert.Equal(t, "quic", summary.SuggestedProtocol) +} + +func TestLogEvent_BothTransportsBlocked(t *testing.T) { + t.Parallel() + entries := captureLogLines(t, bothTransportsBlockedReport()) + + // Both transport rows carry status=fail. + assert.Equal(t, "fail", entries[2].Status) + assert.Equal(t, "Handshake failed", entries[2].Details) + assert.Equal(t, "fail", entries[3].Status) + assert.Equal(t, "Blocked or unreachable", entries[3].Details) + + // Summary: hard fail is true. + summary := entries[len(entries)-1] + require.NotNil(t, summary.HardFail) + assert.True(t, *summary.HardFail) +} + +func TestLogEvent_DNSFail(t *testing.T) { + t.Parallel() + entries := captureLogLines(t, dnsFailReport()) + + // Both DNS rows carry status=fail and the expected details. + assert.Equal(t, "fail", entries[0].Status) + assert.Equal(t, "No addresses returned", entries[0].Details) + assert.Equal(t, "fail", entries[1].Status) + assert.Equal(t, "No addresses returned", entries[1].Details) + + // Transport rows are skipped. + assert.Equal(t, "skip", entries[2].Status) + assert.Equal(t, "DNS prerequisite failed", entries[2].Details) + assert.Equal(t, "skip", entries[3].Status) + assert.Equal(t, "DNS prerequisite failed", entries[3].Details) + + // Summary: hard fail is true. + summary := entries[len(entries)-1] + require.NotNil(t, summary.HardFail) + assert.True(t, *summary.HardFail) +} + +func TestLogEvent_EmptyReport(t *testing.T) { + t.Parallel() + r := Report{RunID: fixedRunID, SuggestedProtocol: connection.HTTP2} + entries := captureLogLines(t, r) + + // No result lines, only the summary. + require.Len(t, entries, 1) + assert.Equal(t, logMsgPrecheckComplete, entries[0].Message) + assert.Equal(t, fixedRunID.String(), entries[0].RunID) + require.NotNil(t, entries[0].HardFail) + assert.False(t, *entries[0].HardFail) + assert.Equal(t, "http2", entries[0].SuggestedProtocol) +} + +// hasHardFail / hasWarn helper tests + +func TestHasHardFail(t *testing.T) { + t.Parallel() + assert.False(t, allPassReport().hasHardFail()) + assert.False(t, quicBlockedReport().hasHardFail()) + assert.False(t, apiFailReport().hasHardFail()) + assert.True(t, bothTransportsBlockedReport().hasHardFail()) + assert.True(t, dnsFailReport().hasHardFail()) +} + +func TestHasWarn(t *testing.T) { + t.Parallel() + assert.False(t, allPassReport().hasWarn()) + assert.True(t, quicBlockedReport().hasWarn()) + assert.True(t, apiFailReport().hasWarn()) + assert.False(t, bothTransportsBlockedReport().hasWarn()) + assert.False(t, dnsFailReport().hasWarn()) +} diff --git a/prechecks/types.go b/prechecks/types.go index ee81c7e9..564a0cc8 100644 --- a/prechecks/types.go +++ b/prechecks/types.go @@ -3,6 +3,8 @@ package prechecks import ( "time" + "github.com/google/uuid" + "github.com/cloudflare/cloudflared/connection" "github.com/cloudflare/cloudflared/edgediscovery/allregions" ) @@ -13,12 +15,9 @@ type Status int const ( // Pass indicates the check completed successfully. Pass Status = iota - // Warn indicates a soft failure: cloudflared can still run but in a - // degraded state (e.g. one transport blocked, API unreachable). - Warn - // Fail indicates a check failure that the user should act on (e.g. - // DNS unresolvable, both transports blocked). cloudflared still starts; - // this status is purely informational. + // Fail indicates the check did not succeed. Whether this is a hard failure + // or a degraded-but-functional state depends on which probe(s) failed — see + // Report.hasHardFail and Report.hasWarn. Fail // Skip indicates the check was not executed because a prerequisite // check (typically DNS) failed first. @@ -30,8 +29,6 @@ func (s Status) String() string { switch s { case Pass: return "PASS" - case Warn: - return "WARN" case Fail: return "FAIL" case Skip: @@ -41,8 +38,24 @@ func (s Status) String() string { } } +// ProbeType identifies which connectivity probe produced a CheckResult. +// It is used by hasHardFail and hasWarn to evaluate severity without +// matching against human-readable strings. +type ProbeType int + +const ( + ProbeTypeDNS ProbeType = iota // DNS resolution + ProbeTypeQUIC // UDP/QUIC transport + ProbeTypeHTTP2 // TCP/HTTP2 transport + ProbeTypeManagementAPI // Cloudflare management API +) + // CheckResult holds the outcome of one individual connectivity probe. type CheckResult struct { + // Type identifies which probe produced this result. Used for severity + // classification in hasHardFail and hasWarn. + Type ProbeType + // Component is the human-readable probe category shown in the table header // column, e.g. "DNS Resolution", "QUIC Connectivity". Component string @@ -58,9 +71,8 @@ type CheckResult struct { // "Resolved successfully" or "Handshake failed". Details string - // Action is non-empty when ProbeStatus is Warn or Fail and contains - // a human-readable remediation instruction, e.g. - // "Allow outbound QUIC on port 7844." + // Action is non-empty when ProbeStatus is Fail and contains a human-readable + // remediation instruction, e.g. "Allow outbound QUIC on port 7844." Action string } @@ -68,6 +80,11 @@ type CheckResult struct { // Pre-checks run in parallel with tunnel initialization and are purely // diagnostic: the Report is displayed to the user but never gates startup. type Report struct { + // RunID is a unique identifier for this pre-check run. It is included in + // every structured log line so that all results from a single invocation + // can be correlated across log aggregation systems. + RunID uuid.UUID + // Results contains one entry per executed probe, in the order they were // collected. Results []CheckResult