TUN-10386: Add Table Renderer
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled

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"}
```
This commit is contained in:
Miguel da Costa Martins Marcelino
2026-04-23 19:04:06 +00:00
parent df54d27710
commit 9f084e6800
3 changed files with 664 additions and 11 deletions
+218
View File
@@ -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)
}
+418
View File
@@ -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())
}
+28 -11
View File
@@ -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