mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
Closes #15584. - The `Collecting Diagnostics` spinner now starts after the workspace build logs (if any) have finished streaming. - Removes network interfaces with negative MTUs from `healthsdk` diagnostics. - Improves the wording on diagnostics for MTUs below the 'safe' value to indicate that direct connections may be degraded, or rendered unusable (i.e. if every packet is dropped).
352 lines
9.3 KiB
Go
352 lines
9.3 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/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"
|
|
"github.com/coder/serpent"
|
|
)
|
|
|
|
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
|
|
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...")
|
|
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 + "/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)
|
|
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)),
|
|
)
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(inv.Stdout, "pong from %s %s in %s\n",
|
|
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),
|
|
},
|
|
}
|
|
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()
|
|
}
|