Files
cloudflared/prechecks/result.go
Miguel da Costa Martins Marcelino cf17ba93b2 TUN-10388: Use pointer for suggested protocol
Switching `SuggestedProtocol` to a pointer, so we can pass nil whenever both QUIC and HTTP2 fail. We should not be suggesting anything when all our protocols have failed.
2026-04-29 13:36:38 +00:00

236 lines
6.4 KiB
Go

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():
if r.SuggestedProtocol == nil {
return "SUMMARY: Environment ready with degraded transport."
}
protocol := r.SuggestedProtocol.String()
return fmt.Sprintf("SUMMARY: Environment ready with degraded transport. cloudflared will proceed using '%s'.", protocol)
default:
if r.SuggestedProtocol == nil {
return "SUMMARY: Environment is healthy."
}
protocol := r.SuggestedProtocol.String()
return fmt.Sprintf("SUMMARY: Environment is healthy. cloudflared will use '%s' as primary protocol.", protocol)
}
}
// 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)
}
if r.SuggestedProtocol != nil {
logger.Info().
Str(logFieldRunID, runID).
Bool(logFieldHardFail, r.hasHardFail()).
Str(logFieldSuggestedProtocol, r.SuggestedProtocol.String()).
Msg(logMsgPrecheckComplete)
} else {
logger.Info().
Str(logFieldRunID, runID).
Bool(logFieldHardFail, r.hasHardFail()).
Msg(logMsgPrecheckComplete)
}
}