mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
5e84d257b7
Fixes https://github.com/coder/internal/issues/907 We convert `workspacesdk.AgentConn` to an interface and generate a mock for it. This allows writing `coderd` tests that rely on the agent's HTTP api to not have to set up an entire tailnet networking stack.
377 lines
9.9 KiB
Go
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.Max.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.TailnetConn().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.TailnetConn().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()
|
|
}
|