Files
coder/cli/ping.go
Danny Kopping 4c438bd4d3 feat(cli): add local and UTC time options to ping cmd (#16648)
It's sometimes useful to see when each pong was received, for
correlating these times with other events.

---------

Signed-off-by: Danny Kopping <danny@coder.com>
2025-02-24 05:38:17 +00:00

377 lines
9.9 KiB
Go

package cli
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/netip"
"strings"
"time"
"golang.org/x/xerrors"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/briandowns/spinner"
"github.com/coder/pretty"
"github.com/coder/serpent"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/cliutil"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/healthsdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
type pingSummary struct {
Workspace string `table:"workspace,nosort"`
Total int `table:"total"`
Successful int `table:"successful"`
Min *time.Duration `table:"min"`
Avg *time.Duration `table:"avg"`
Max *time.Duration `table:"max"`
Variance *time.Duration `table:"variance"`
latencySum float64
runningAvg float64
m2 float64
}
func (s *pingSummary) addResult(r *ipnstate.PingResult) {
s.Total++
if r == nil || r.Err != "" {
return
}
s.Successful++
if s.Min == nil || r.LatencySeconds < s.Min.Seconds() {
s.Min = ptr.Ref(time.Duration(r.LatencySeconds * float64(time.Second)))
}
if s.Max == nil || r.LatencySeconds > s.Min.Seconds() {
s.Max = ptr.Ref(time.Duration(r.LatencySeconds * float64(time.Second)))
}
s.latencySum += r.LatencySeconds
d := r.LatencySeconds - s.runningAvg
s.runningAvg += d / float64(s.Successful)
d2 := r.LatencySeconds - s.runningAvg
s.m2 += d * d2
}
// Write finalizes the summary and writes it
func (s *pingSummary) Write(w io.Writer) {
if s.Successful > 0 {
s.Avg = ptr.Ref(time.Duration(s.latencySum / float64(s.Successful) * float64(time.Second)))
}
if s.Successful > 1 {
s.Variance = ptr.Ref(time.Duration((s.m2 / float64(s.Successful-1)) * float64(time.Second)))
}
out, err := cliui.DisplayTable([]*pingSummary{s}, "", nil)
if err != nil {
_, _ = fmt.Fprintf(w, "Failed to display ping summary: %v\n", err)
return
}
width := len(strings.Split(out, "\n")[0])
_, _ = fmt.Println(strings.Repeat("-", width))
_, _ = fmt.Fprint(w, out)
}
func (r *RootCmd) ping() *serpent.Command {
var (
pingNum int64
pingTimeout time.Duration
pingWait time.Duration
pingTimeLocal bool
pingTimeUTC bool
appearanceConfig codersdk.AppearanceConfig
)
client := new(codersdk.Client)
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "ping <workspace>",
Short: "Ping a workspace",
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
r.InitClient(client),
initAppearance(client, &appearanceConfig),
),
Handler: func(inv *serpent.Invocation) error {
ctx, cancel := context.WithCancel(inv.Context())
defer cancel()
notifyCtx, notifyCancel := inv.SignalNotifyContext(ctx, StopSignals...)
defer notifyCancel()
workspaceName := inv.Args[0]
_, workspaceAgent, err := getWorkspaceAndAgent(
ctx, inv, client,
false, // Do not autostart for a ping.
workspaceName,
)
if err != nil {
return err
}
// Start spinner after any build logs have finished streaming
spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond)
spin.Writer = inv.Stderr
spin.Suffix = pretty.Sprint(cliui.DefaultStyles.Keyword, " Collecting diagnostics...")
if !r.verbose {
spin.Start()
}
opts := &workspacesdk.DialAgentOptions{}
if r.verbose {
opts.Logger = inv.Logger.AppendSinks(sloghuman.Sink(inv.Stdout)).Leveled(slog.LevelDebug)
}
if r.disableDirect {
opts.BlockEndpoints = true
}
if !r.disableNetworkTelemetry {
opts.EnableTelemetry = true
}
wsClient := workspacesdk.New(client)
conn, err := wsClient.DialAgent(ctx, workspaceAgent.ID, opts)
if err != nil {
spin.Stop()
return err
}
defer conn.Close()
derpMap := conn.DERPMap()
diagCtx, diagCancel := context.WithTimeout(inv.Context(), 30*time.Second)
defer diagCancel()
diags := conn.GetPeerDiagnostics()
// Silent ping to determine whether we should show diags
_, didP2p, _, _ := conn.Ping(ctx)
ni := conn.GetNetInfo()
connDiags := cliui.ConnDiags{
DisableDirect: r.disableDirect,
LocalNetInfo: ni,
Verbose: r.verbose,
PingP2P: didP2p,
TroubleshootingURL: appearanceConfig.DocsURL + "/admin/networking/troubleshooting",
}
awsRanges, err := cliutil.FetchAWSIPRanges(diagCtx, cliutil.AWSIPRangesURL)
if err != nil {
opts.Logger.Debug(inv.Context(), "failed to retrieve AWS IP ranges", slog.Error(err))
}
connDiags.ClientIPIsAWS = isAWSIP(awsRanges, ni)
connInfo, err := wsClient.AgentConnectionInfoGeneric(diagCtx)
if err != nil || connInfo.DERPMap == nil {
spin.Stop()
return xerrors.Errorf("Failed to retrieve connection info from server: %w\n", err)
}
connDiags.ConnInfo = connInfo
ifReport, err := healthsdk.RunInterfacesReport()
if err == nil {
connDiags.LocalInterfaces = &ifReport
} else {
_, _ = fmt.Fprintf(inv.Stdout, "Failed to retrieve local interfaces report: %v\n", err)
}
agentNetcheck, err := conn.Netcheck(diagCtx)
if err == nil {
connDiags.AgentNetcheck = &agentNetcheck
connDiags.AgentIPIsAWS = isAWSIP(awsRanges, agentNetcheck.NetInfo)
} else {
var sdkErr *codersdk.Error
if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound {
_, _ = fmt.Fprint(inv.Stdout, "Could not generate full connection report as the workspace agent is outdated\n")
} else {
_, _ = fmt.Fprintf(inv.Stdout, "Failed to retrieve connection report from agent: %v\n", err)
}
}
spin.Stop()
cliui.PeerDiagnostics(inv.Stderr, diags)
connDiags.Write(inv.Stderr)
results := &pingSummary{
Workspace: workspaceName,
}
var (
pong *ipnstate.PingResult
dur time.Duration
p2p bool
)
n := 0
start := time.Now()
pingLoop:
for {
if n > 0 {
time.Sleep(pingWait)
}
n++
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
dur, p2p, pong, err = conn.Ping(ctx)
pongTime := time.Now()
if pingTimeUTC {
pongTime = pongTime.UTC()
}
cancel()
results.addResult(pong)
if err != nil {
if xerrors.Is(err, context.DeadlineExceeded) {
_, _ = fmt.Fprintf(inv.Stdout, "ping to %q timed out \n", workspaceName)
if n == int(pingNum) {
return nil
}
continue
}
if xerrors.Is(err, context.Canceled) {
return nil
}
if err.Error() == "no matching peer" {
continue
}
_, _ = fmt.Fprintf(inv.Stdout, "ping to %q failed %s\n", workspaceName, err.Error())
if n == int(pingNum) {
return nil
}
continue
}
dur = dur.Round(time.Millisecond)
var via string
if p2p {
if !didP2p {
_, _ = fmt.Fprintln(inv.Stdout, "p2p connection established in",
pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, time.Since(start).Round(time.Millisecond).String()),
)
}
didP2p = true
via = fmt.Sprintf("%s via %s",
pretty.Sprint(cliui.DefaultStyles.Fuchsia, "p2p"),
pretty.Sprint(cliui.DefaultStyles.Code, pong.Endpoint),
)
} else {
derpName := "unknown"
derpRegion, ok := derpMap.Regions[pong.DERPRegionID]
if ok {
derpName = derpRegion.RegionName
}
via = fmt.Sprintf("%s via %s",
pretty.Sprint(cliui.DefaultStyles.Fuchsia, "proxied"),
pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("DERP(%s)", derpName)),
)
}
var displayTime string
if pingTimeLocal || pingTimeUTC {
displayTime = pretty.Sprintf(cliui.DefaultStyles.DateTimeStamp, "[%s] ", pongTime.Format(time.RFC3339))
}
_, _ = fmt.Fprintf(inv.Stdout, "%spong from %s %s in %s\n",
displayTime,
pretty.Sprint(cliui.DefaultStyles.Keyword, workspaceName),
via,
pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, dur.String()),
)
select {
case <-notifyCtx.Done():
break pingLoop
default:
if n == int(pingNum) {
break pingLoop
}
}
}
if p2p {
msg := "✔ You are connected directly (p2p)"
if pong != nil && isPrivateEndpoint(pong.Endpoint) {
msg += ", over a private network"
}
_, _ = fmt.Fprintln(inv.Stderr, msg)
} else {
_, _ = fmt.Fprintf(inv.Stderr, "❗ You are connected via a DERP relay, not directly (p2p)\n"+
" %s#common-problems-with-direct-connections\n", connDiags.TroubleshootingURL)
}
results.Write(inv.Stdout)
return nil
},
}
cmd.Options = serpent.OptionSet{
{
Flag: "wait",
Description: "Specifies how long to wait between pings.",
Default: "1s",
Value: serpent.DurationOf(&pingWait),
},
{
Flag: "timeout",
FlagShorthand: "t",
Default: "5s",
Description: "Specifies how long to wait for a ping to complete.",
Value: serpent.DurationOf(&pingTimeout),
},
{
Flag: "num",
FlagShorthand: "n",
Description: "Specifies the number of pings to perform. By default, pings will continue until interrupted.",
Value: serpent.Int64Of(&pingNum),
},
{
Flag: "time",
Description: "Show the response time of each pong in local time.",
Value: serpent.BoolOf(&pingTimeLocal),
},
{
Flag: "utc",
Description: "Show the response time of each pong in UTC (implies --time).",
Value: serpent.BoolOf(&pingTimeUTC),
},
}
return cmd
}
func isAWSIP(awsRanges *cliutil.AWSIPRanges, ni *tailcfg.NetInfo) bool {
if awsRanges == nil {
return false
}
if ni.GlobalV4 != "" {
ip, err := netip.ParseAddr(ni.GlobalV4)
if err == nil && awsRanges.CheckIP(ip) {
return true
}
}
if ni.GlobalV6 != "" {
ip, err := netip.ParseAddr(ni.GlobalV6)
if err == nil && awsRanges.CheckIP(ip) {
return true
}
}
return false
}
func isPrivateEndpoint(endpoint string) bool {
ip, err := netip.ParseAddrPort(endpoint)
if err != nil {
return false
}
return ip.Addr().IsPrivate()
}