Skip to content

Commit 4177dd6

Browse files
committed
TUN-10391: Avoid using fmt.Println
Avoid using fmt.Println and instead switch to logging pre-checks with the provided logger.
1 parent f6f60e1 commit 4177dd6

7 files changed

Lines changed: 276 additions & 183 deletions

File tree

cmd/cloudflared/cliutil/logger.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package cliutil
22

33
import (
4+
"strings"
5+
6+
"github.com/rs/zerolog"
47
"github.com/urfave/cli/v2"
58
"github.com/urfave/cli/v2/altsrc"
69

@@ -57,3 +60,57 @@ func ConfigureLoggingFlags(shouldHide bool) []cli.Flag {
5760
FlagLogOutput,
5861
}
5962
}
63+
64+
// LogTable renders lines inside an ASCII table and logs each rendered row.
65+
func LogTable(log *zerolog.Logger, lines []string, title ...string) {
66+
tableTitle := ""
67+
if len(title) > 0 {
68+
tableTitle = title[0]
69+
}
70+
for _, line := range asciiBox(lines, tableTitle, 2) {
71+
if line != "" {
72+
log.Info().Msg(line)
73+
}
74+
}
75+
}
76+
77+
// asciiBox wraps lines in a bordered ASCII box with an optional title row.
78+
func asciiBox(lines []string, title string, padding int) (box []string) {
79+
maxLen := maxLen(lines, title)
80+
spacer := strings.Repeat(" ", padding)
81+
border := "+" + strings.Repeat("-", maxLen+(padding*2)) + "+"
82+
box = append(box, border)
83+
if title != "" {
84+
box = append(box, renderBoxLine(centerLine(title, maxLen), maxLen, spacer))
85+
box = append(box, border)
86+
}
87+
for _, line := range lines {
88+
box = append(box, renderBoxLine(line, maxLen, spacer))
89+
}
90+
box = append(box, border)
91+
return
92+
}
93+
94+
// renderBoxLine pads a single line so it fills the box width.
95+
func renderBoxLine(line string, maxLen int, spacer string) string {
96+
return "|" + spacer + line + strings.Repeat(" ", maxLen-len(line)) + spacer + "|"
97+
}
98+
99+
// centerLine pads line evenly so it is centered within width.
100+
func centerLine(line string, width int) string {
101+
padding := width - len(line)
102+
leftPadding := padding / 2
103+
rightPadding := padding - leftPadding
104+
return strings.Repeat(" ", leftPadding) + line + strings.Repeat(" ", rightPadding)
105+
}
106+
107+
// maxLen returns the longest visible line length including the title.
108+
func maxLen(lines []string, title string) int {
109+
max := len(title)
110+
for _, line := range lines {
111+
if len(line) > max {
112+
max = len(line)
113+
}
114+
}
115+
return max
116+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package cliutil
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/rs/zerolog"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestLogTableWithoutTitle(t *testing.T) {
14+
t.Parallel()
15+
16+
lines := captureTableLogs(t, []string{"first", "second"})
17+
18+
assert.Equal(t, []string{
19+
"+----------+",
20+
"| first |",
21+
"| second |",
22+
"+----------+",
23+
}, lines)
24+
}
25+
26+
func TestLogTableWithTitle(t *testing.T) {
27+
t.Parallel()
28+
29+
lines := captureTableLogs(t, []string{"first", "second"}, "TT")
30+
31+
assert.Equal(t, []string{
32+
"+----------+",
33+
"| TT |",
34+
"+----------+",
35+
"| first |",
36+
"| second |",
37+
"+----------+",
38+
}, lines)
39+
}
40+
41+
func captureTableLogs(t *testing.T, lines []string, title ...string) []string {
42+
t.Helper()
43+
44+
var buf bytes.Buffer
45+
logger := zerolog.New(&buf)
46+
47+
LogTable(&logger, lines, title...)
48+
49+
// nolint: prealloc
50+
var messages []string
51+
for _, line := range bytes.Split(bytes.TrimSpace(buf.Bytes()), []byte("\n")) {
52+
var entry struct {
53+
Message string `json:"message"`
54+
}
55+
require.NoError(t, json.Unmarshal(line, &entry))
56+
messages = append(messages, entry.Message)
57+
}
58+
59+
return messages
60+
}

cmd/cloudflared/tunnel/cmd.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -551,8 +551,8 @@ func runPrechecks(c *cli.Context, log *zerolog.Logger, region string) {
551551

552552
report := prechecks.Run(c.Context, c.String(cfdflags.CACert), cfg, log, dialers)
553553

554-
// Output the human-readable table to console
555-
fmt.Println(report.String())
554+
// Output the human-readable table
555+
cliutil.LogTable(log, report.String(), "CONNECTIVITY PRE-CHECKS")
556556

557557
// Also log structured results for log aggregation
558558
report.LogEvent(log)

cmd/cloudflared/tunnel/quick_tunnel.go

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/google/uuid"
1212
"github.com/pkg/errors"
1313

14+
"github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil"
1415
"github.com/cloudflare/cloudflared/cmd/cloudflared/flags"
1516
"github.com/cloudflare/cloudflared/connection"
1617
)
@@ -44,7 +45,7 @@ func RunQuickTunnel(sc *subcommandContext) error {
4445
if err != nil {
4546
return errors.Wrap(err, "failed to request quick Tunnel")
4647
}
47-
defer resp.Body.Close()
48+
defer func() { _ = resp.Body.Close() }()
4849

4950
// This will read the entire response into memory so we can print it in case of error
5051
rsp_body, err := io.ReadAll(resp.Body)
@@ -76,12 +77,10 @@ func RunQuickTunnel(sc *subcommandContext) error {
7677
url = "https://" + url
7778
}
7879

79-
for _, line := range AsciiBox([]string{
80+
cliutil.LogTable(sc.log, []string{
8081
"Your quick Tunnel has been created! Visit it at (it may take some time to be reachable):",
8182
url,
82-
}, 2) {
83-
sc.log.Info().Msg(line)
84-
}
83+
})
8584

8685
if !sc.c.IsSet(flags.Protocol) {
8786
_ = sc.c.Set(flags.Protocol, "quic")
@@ -116,26 +115,3 @@ type QuickTunnel struct {
116115
AccountTag string `json:"account_tag"`
117116
Secret []byte `json:"secret"`
118117
}
119-
120-
// Print out the given lines in a nice ASCII box.
121-
func AsciiBox(lines []string, padding int) (box []string) {
122-
maxLen := maxLen(lines)
123-
spacer := strings.Repeat(" ", padding)
124-
border := "+" + strings.Repeat("-", maxLen+(padding*2)) + "+"
125-
box = append(box, border)
126-
for _, line := range lines {
127-
box = append(box, "|"+spacer+line+strings.Repeat(" ", maxLen-len(line))+spacer+"|")
128-
}
129-
box = append(box, border)
130-
return
131-
}
132-
133-
func maxLen(lines []string) int {
134-
max := 0
135-
for _, line := range lines {
136-
if len(line) > max {
137-
max = len(line)
138-
}
139-
}
140-
return max
141-
}

component-tests/test_prechecks.py

Lines changed: 82 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
Scope
66
-----
77
These tests verify the end-to-end behavior of cloudflared pre-checks:
8-
- that the human-readable table written to stdout has the correct structure
9-
and content,
8+
- that the human-readable table written to the log output has the correct
9+
structure and content,
1010
- that structured JSON log lines are emitted with the expected fields, and
1111
- that running the `diag` subcommand against a live tunnel instance produces a
1212
zip archive that contains prechecks.json.
@@ -25,15 +25,24 @@
2525
DNS failure and Management API failure cannot be triggered via CLI flags alone;
2626
they require network-level intervention outside the component-test harness.
2727
28-
stdout design
29-
-------------
30-
fmt.Println(report.String()) runs inside a goroutine that is started
31-
concurrently with the tunnel. We poll a --logfile for the "precheck complete"
32-
sentinel before leaving the `with` block, ensuring the goroutine has finished.
33-
We then call cfd.terminate(). After the `with` block exits, the process is
34-
dead and all output has been captured by CloudflaredProcess's background reader
35-
thread (stderr is merged into stdout). We read the accumulated lines from
36-
cfd.stdout_lines.
28+
stdout/stderr design
29+
--------------------
30+
The pre-checks table is emitted via cliutil.LogTable, which wraps the content
31+
in an ASCII box and logs each line at Info level through zerolog. zerolog
32+
writes to stderr, which the test harness merges into stdout (stderr=STDOUT in
33+
Popen). We poll a --logfile for the "precheck complete" sentinel before
34+
leaving the `with` block, ensuring the goroutine has finished. We then call
35+
cfd.terminate(). After the `with` block exits, the process is dead and all
36+
output has been captured by CloudflaredProcess's background reader thread. We
37+
read the accumulated lines from cfd.stdout_lines.
38+
39+
Box format (cliutil.asciiBox with padding=2, title="CONNECTIVITY PRE-CHECKS"):
40+
+----...----+
41+
| CONNECTIVITY PRE-CHECKS | (centered title)
42+
+----...----+
43+
| COMPONENT TARGET ... | (content rows)
44+
...
45+
+----...----+
3746
"""
3847

3948
import json
@@ -46,11 +55,13 @@
4655
from constants import METRICS_PORT
4756
from util import LOGGER, start_cloudflared, wait_tunnel_ready
4857

49-
# stdout table constants
50-
TABLE_WIDTH = 80
51-
HEADER_LINE = "--- CONNECTIVITY PRE-CHECKS " + "-" * (TABLE_WIDTH - len("--- CONNECTIVITY PRE-CHECKS ") - 1)
52-
COL_HEADER = "COMPONENT" # first token of the column-header row
53-
SEPARATOR = "-" * TABLE_WIDTH
58+
# ASCII box constants (cliutil.asciiBox, padding=2, title="CONNECTIVITY PRE-CHECKS")
59+
BOX_TITLE = "CONNECTIVITY PRE-CHECKS"
60+
BOX_BORDER_RE = re.compile(r"^\+(-+)\+$", re.MULTILINE) # matches +----...----+
61+
COL_HEADER = "COMPONENT" # first word of the column-header row
62+
63+
# zerolog console format: "2006-01-02T15:04:05Z LVL <message>"
64+
_LOG_PREFIX_RE = re.compile(r"^\S+ \w+ ")
5465

5566
# Component names (probes.go: componentXxx)
5667
COMP_DNS = "DNS Resolution"
@@ -164,23 +175,46 @@ def __repr__(self):
164175
return f"TableRow({self.component!r}, {self.target!r}, {self.status!r}, {self.details!r})"
165176

166177

178+
def _strip_log_prefix(line: str) -> str:
179+
"""Remove the zerolog console prefix ('2006-01-02T15:04:05Z LVL ') if present."""
180+
return _LOG_PREFIX_RE.sub("", line, count=1)
181+
182+
183+
def _unbox_line(line: str) -> str:
184+
"""Strip the box border padding from a content line: '| text |' -> 'text'.
185+
186+
Accepts lines that may still carry a zerolog console prefix; the prefix is
187+
removed before the box delimiters are stripped.
188+
"""
189+
msg = _strip_log_prefix(line)
190+
if msg.startswith("|") and msg.endswith("|"):
191+
return msg[1:-1].strip()
192+
return msg.strip()
193+
194+
167195
def _parse_table(stdout: str) -> list[TableRow]:
168196
"""
169197
Parse the data rows from a precheck table in stdout.
170198
171-
text/tabwriter uses padding=2, so columns are separated by two or more
172-
spaces. We skip the column-header row and stop at blank lines, SUMMARY,
173-
separator, ERROR, or WARNING lines.
199+
The table is now wrapped in an ASCII box by cliutil.LogTable. Each
200+
content line has the form '| <content> |', optionally preceded by a
201+
zerolog console prefix. We strip both the prefix and the box borders
202+
before splitting on two-or-more spaces (text/tabwriter padding=2).
203+
204+
We skip the column-header row and stop at blank lines, SUMMARY, box
205+
border lines, ERROR, or WARNING lines.
174206
"""
175207
rows = []
176208
in_data = False
177-
for line in stdout.splitlines():
209+
for raw_line in stdout.splitlines():
210+
msg = _strip_log_prefix(raw_line)
211+
line = _unbox_line(raw_line)
178212
if line.startswith("COMPONENT"):
179213
in_data = True
180214
continue
181215
if not in_data:
182216
continue
183-
if (line == "" or line.startswith("SUMMARY") or line.startswith("---")
217+
if (line == "" or line.startswith("SUMMARY") or BOX_BORDER_RE.match(msg)
184218
or line.startswith("ERROR") or line.startswith("WARNING")):
185219
in_data = False
186220
continue
@@ -261,14 +295,18 @@ def test_prechecks_pass_on_healthy_connection(self, tmp_path, component_tests_co
261295
LOGGER.debug(f"[happy-path] stdout:\n{stdout}")
262296
LOGGER.debug(f"[happy-path] log_lines:\n{log_lines}")
263297

298+
# Strip zerolog console prefixes so pattern matching works on raw messages.
299+
messages = "\n".join(_strip_log_prefix(l) for l in stdout.splitlines())
300+
264301
# ── table structure ──────────────────────────────────────────────────
265-
# stderr is merged into stdout so log lines precede the table.
266-
assert HEADER_LINE in stdout, \
267-
f"Expected header line in output;\ngot:\n{stdout}"
268-
assert COL_HEADER in stdout, \
302+
# zerolog writes to stderr which is merged into stdout by the harness.
303+
# The table is wrapped in an ASCII box by cliutil.LogTable.
304+
assert BOX_TITLE in messages, \
305+
f"Expected box title '{BOX_TITLE}' in output;\ngot:\n{stdout}"
306+
assert COL_HEADER in messages, \
269307
f"Expected column header row in output;\ngot:\n{stdout}"
270-
assert SEPARATOR in stdout, \
271-
f"Expected closing separator in output;\ngot:\n{stdout}"
308+
assert BOX_BORDER_RE.search(messages), \
309+
f"Expected box border line (+---+) in output;\ngot:\n{stdout}"
272310

273311
# ── row content ──────────────────────────────────────────────────────
274312
rows = _parse_table(stdout)
@@ -306,11 +344,11 @@ def test_prechecks_pass_on_healthy_connection(self, tmp_path, component_tests_co
306344
assert api_rows[0].details == DETAILS_API_OK, f"API row details wrong: {api_rows[0]}"
307345

308346
# ── no action lines ──────────────────────────────────────────────────
309-
assert PREFIX_ERROR not in stdout, f"Unexpected ERROR action:\n{stdout}"
310-
assert PREFIX_WARNING not in stdout, f"Unexpected WARNING action:\n{stdout}"
347+
assert PREFIX_ERROR not in messages, f"Unexpected ERROR action:\n{stdout}"
348+
assert PREFIX_WARNING not in messages, f"Unexpected WARNING action:\n{stdout}"
311349

312-
# ── exact summary line ───────────────────────────────────────────────
313-
assert SUMMARY_HEALTHY in stdout, \
350+
# ── summary line ─────────────────────────────────────────────────────
351+
assert SUMMARY_HEALTHY in messages, \
314352
f"Expected healthy summary;\ngot:\n{stdout}"
315353

316354
# ── structured log ───────────────────────────────────────────────────
@@ -366,14 +404,18 @@ def test_prechecks_hard_fail_when_edge_unreachable(self, tmp_path, component_tes
366404
LOGGER.debug(f"[hard-fail] stdout:\n{stdout}")
367405
LOGGER.debug(f"[hard-fail] log_lines:\n{log_lines}")
368406

407+
# Strip zerolog console prefixes so pattern matching works on raw messages.
408+
messages = "\n".join(_strip_log_prefix(l) for l in stdout.splitlines())
409+
369410
# ── table structure ──────────────────────────────────────────────────
370-
# stderr is merged into stdout so log lines precede the table.
371-
assert HEADER_LINE in stdout, \
372-
f"Expected header line in output;\ngot:\n{stdout}"
373-
assert COL_HEADER in stdout, \
411+
# zerolog writes to stderr which is merged into stdout by the harness.
412+
# The table is wrapped in an ASCII box by cliutil.LogTable.
413+
assert BOX_TITLE in messages, \
414+
f"Expected box title '{BOX_TITLE}' in output;\ngot:\n{stdout}"
415+
assert COL_HEADER in messages, \
374416
f"Expected column header row in output;\ngot:\n{stdout}"
375-
assert SEPARATOR in stdout, \
376-
f"Expected closing separator in output;\ngot:\n{stdout}"
417+
assert BOX_BORDER_RE.search(messages), \
418+
f"Expected box border line (+---+) in output;\ngot:\n{stdout}"
377419

378420
# ── row content ──────────────────────────────────────────────────────
379421
rows = _parse_table(stdout)
@@ -404,12 +446,12 @@ def test_prechecks_hard_fail_when_edge_unreachable(self, tmp_path, component_tes
404446
assert api_rows[0].status == PASS, f"API row not PASS: {api_rows[0]}"
405447
assert api_rows[0].details == DETAILS_API_OK, f"API row details wrong: {api_rows[0]}"
406448

407-
assert f"{PREFIX_ERROR}{ACTION_QUIC_BLOCKED}" in stdout, \
449+
assert f"{PREFIX_ERROR}{ACTION_QUIC_BLOCKED}" in messages, \
408450
f"Expected QUIC ERROR action;\ngot:\n{stdout}"
409-
assert f"{PREFIX_ERROR}{ACTION_HTTP2_BLOCKED}" in stdout, \
451+
assert f"{PREFIX_ERROR}{ACTION_HTTP2_BLOCKED}" in messages, \
410452
f"Expected HTTP/2 ERROR action;\ngot:\n{stdout}"
411453

412-
assert SUMMARY_CRITICAL in stdout, \
454+
assert SUMMARY_CRITICAL in messages, \
413455
f"Expected critical summary;\ngot:\n{stdout}"
414456

415457
_assert_precheck_summary_log(log_lines, hard_fail=True, suggested_protocol=None)

0 commit comments

Comments
 (0)