mirror of
https://github.com/cloudflare/cloudflared.git
synced 2026-06-23 04:10:20 +00:00
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
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:
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user