Ctrl K
ComunidadGuías…

cli/ping.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package cli

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/netip"
	"strings"
	"time"

	"github.com/briandowns/spinner"
	"golang.org/x/xerrors"
	"tailscale.com/ipn/ipnstate"
	"tailscale.com/tailcfg"

	"cdr.dev/slog/v3"
	"cdr.dev/slog/v3/sloggers/sloghuman"
	"github.com/coder/coder/v2/cli/cliui"
	"github.com/coder/coder/v2/cli/cliutil"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/coder/v2/codersdk/healthsdk"
	"github.com/coder/coder/v2/codersdk/workspacesdk"
	"github.com/coder/pretty"
	"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 = new(time.Duration(r.LatencySeconds * float64(time.Second)))
	}
	if s.Max == nil || r.LatencySeconds > s.Max.Seconds() {
		s.Max = new(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
}

// finalize computes the derived Avg and Variance fields from the
// accumulated results. It must be called before the summary is
// displayed or serialized.
func (s *pingSummary) finalize() {
	if s.Successful > 0 {
		s.Avg = new(time.Duration(s.latencySum / float64(s.Successful) * float64(time.Second)))
	}
	if s.Successful > 1 {
		s.Variance = new(time.Duration((s.m2 / float64(s.Successful-1)) * float64(time.Second)))
	}
}

// pingResult is the data handed to the output formatter once pinging ends.
// pongs is only populated in JSON mode.
type pingResult struct {
	summary *pingSummary
	pongs   []PingJSONPong
}

// pingTableFormat renders the summary table under a dashed rule that
// separates it from the per-attempt lines above.
type pingTableFormat struct {
	cliui.OutputFormat
}

func (f pingTableFormat) Format(ctx context.Context, data any) (string, error) {
	out, err := f.OutputFormat.Format(ctx, data)
	if err != nil {
		return "", err
	}
	width := len(strings.Split(out, "\n")[0])
	return strings.Repeat("-", width) + "\n" + out, nil
}

func newPingFormatter() *cliui.OutputFormatter {
	return cliui.NewOutputFormatter(
		pingTableFormat{cliui.ChangeFormatterData(
			cliui.TableFormat([]*pingSummary{}, nil),
			func(data any) (any, error) {
				res, ok := data.(pingResult)
				if !ok {
					return nil, xerrors.Errorf("expected pingResult, got %T", data)
				}
				return []*pingSummary{res.summary}, nil
			},
		)},
		cliui.ChangeFormatterData(cliui.JSONFormat(), func(data any) (any, error) {
			res, ok := data.(pingResult)
			if !ok {
				return nil, xerrors.Errorf("expected pingResult, got %T", data)
			}
			return PingJSONOutput{
				Workspace: res.summary.Workspace,
				Pongs:     res.pongs,
				Summary:   res.summary.jsonSummary(),
			}, nil
		}),
	)
}

// PingJSONPong is one ping attempt. Error is set when the attempt failed.
type PingJSONPong struct {
	Sequence   int        `json:"sequence"`
	Time       *time.Time `json:"time,omitempty"`
	LatencyMS  *float64   `json:"latency_ms,omitempty"`
	Direct     *bool      `json:"direct,omitempty"`
	DERPRegion string     `json:"derp_region,omitempty"`
	Error      string     `json:"error,omitempty"`
}

// PingJSONSummary mirrors the fields shown in the ping summary table.
type PingJSONSummary struct {
	Total      int      `json:"total"`
	Successful int      `json:"successful"`
	MinMS      *float64 `json:"min_ms,omitempty"`
	AvgMS      *float64 `json:"avg_ms,omitempty"`
	MaxMS      *float64 `json:"max_ms,omitempty"`
	VarianceMS *float64 `json:"variance_ms,omitempty"`
}

// PingJSONOutput is the single document `coder ping -o json` prints to stdout
// after the last attempt.
type PingJSONOutput struct {
	Workspace string          `json:"workspace"`
	Pongs     []PingJSONPong  `json:"pongs"`
	Summary   PingJSONSummary `json:"summary"`
}

func durationMS(d *time.Duration) *float64 {
	if d == nil {
		return nil
	}
	ms := float64(*d) / float64(time.Millisecond)
	return &ms
}

// jsonSummary builds the JSON summary. finalize must be called first.
func (s *pingSummary) jsonSummary() PingJSONSummary {
	return PingJSONSummary{
		Total:      s.Total,
		Successful: s.Successful,
		MinMS:      durationMS(s.Min),
		AvgMS:      durationMS(s.Avg),
		MaxMS:      durationMS(s.Max),
		VarianceMS: durationMS(s.Variance),
	}
}

func (r *RootCmd) ping() *serpent.Command {
	var (
		pingNum       int64
		pingTimeout   time.Duration
		pingWait      time.Duration
		pingTimeLocal bool
		pingTimeUTC   bool
		formatter     = newPingFormatter()
	)

	cmd := &serpent.Command{
		Annotations: workspaceCommand,
		Use:         "ping <workspace>",
		Short:       "Ping a workspace",
		Middleware: serpent.Chain(
			serpent.RequireNArgs(1),
		),
		Handler: func(inv *serpent.Invocation) error {
			// JSON output is a single document printed when pinging ends. Without
			// --num, pinging continues until interrupted, so stdout would stay
			// empty and every pong would be held in memory until then.
			jsonOutput := formatter.FormatID() == "json"
			if jsonOutput && pingNum <= 0 {
				return xerrors.New("--output json requires --num, because pings continue until interrupted without it")
			}

			client, err := r.InitClient(inv)
			if err != nil {
				return err
			}
			ctx, cancel := context.WithCancel(inv.Context())
			defer cancel()
			appearanceConfig := initAppearance(ctx, client)
			notifyCtx, notifyCancel := inv.SignalNotifyContext(ctx, StopSignals...)
			defer notifyCancel()

			// In JSON mode, stdout carries a single JSON document at the
			// end. Diagnostic and per-attempt lines that would otherwise go
			// to stdout are redirected to stderr so they cannot corrupt the
			// JSON output. Default (table) output is unaffected.
			diagOut := inv.Stdout
			if jsonOutput {
				diagOut = inv.Stderr
			}

			workspaceName := inv.Args[0]
			workspace, workspaceAgent, otherAgents, err := GetWorkspaceAndAgent(
				ctx, inv, client,
				false, // Do not autostart for a ping.
				workspaceName,
			)
			if err != nil {
				if len(otherAgents) == 0 {
					return err
				}
				if suggestWithOwner(workspaceName, workspace) {
					workspaceName = workspace.FullName()
				} else {
					workspaceName = workspace.Name
				}
				var message strings.Builder
				_, _ = fmt.Fprintf(&message, "%s\nTry running:\n", err)
				for _, agent := range otherAgents {
					command := fmt.Sprintf("$ coder ping %s.%s", workspaceName, agent.Name)
					_, _ = fmt.Fprintf(&message, "\t%s\n", cliui.Code(command))
				}
				return xerrors.New(message.String())
			}

			// 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(diagOut)).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(diagOut, "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(diagOut, "Could not generate full connection report as the workspace agent is outdated\n")
				} else {
					_, _ = fmt.Fprintf(diagOut, "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 pongs []PingJSONPong
			// Pongs are only kept for JSON output, where --num bounds them.
			// Table mode can run until interrupted, so it keeps nothing.
			recordPong := func(p PingJSONPong) {
				if jsonOutput {
					pongs = append(pongs, p)
				}
			}
			writeResult := func() error {
				results.finalize()
				out, err := formatter.Format(inv.Context(), pingResult{summary: results, pongs: pongs})
				if err != nil {
					return xerrors.Errorf("format ping result: %w", err)
				}
				_, err = fmt.Fprintln(inv.Stdout, out)
				return err
			}
			// finishEarly ends pinging when the last attempt fails or the
			// context is canceled. Table output skips the summary in these
			// cases, but JSON output always prints its document.
			finishEarly := func() error {
				if jsonOutput {
					return writeResult()
				}
				return nil
			}
			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(diagOut, "ping to %q timed out \n", workspaceName)
						recordPong(PingJSONPong{
							Sequence: n,
							Time:     &pongTime,
							Error:    "timed out",
						})
						if n == int(pingNum) {
							return finishEarly()
						}
						continue
					}
					if xerrors.Is(err, context.Canceled) {
						return finishEarly()
					}

					if err.Error() == "no matching peer" {
						recordPong(PingJSONPong{
							Sequence: n,
							Time:     &pongTime,
							Error:    err.Error(),
						})
						continue
					}

					_, _ = fmt.Fprintf(diagOut, "ping to %q failed %s\n", workspaceName, err.Error())
					recordPong(PingJSONPong{
						Sequence: n,
						Time:     &pongTime,
						Error:    err.Error(),
					})
					if n == int(pingNum) {
						return finishEarly()
					}
					continue
				}

				dispDur := dur.Round(time.Millisecond)
				var via string
				var derpRegionName string
				direct := p2p
				if p2p {
					if !didP2p {
						_, _ = fmt.Fprintln(diagOut, "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
					}
					derpRegionName = derpName
					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(diagOut, "%spong from %s %s in %s\n",
					displayTime,
					pretty.Sprint(cliui.DefaultStyles.Keyword, workspaceName),
					via,
					pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, dispDur.String()),
				)

				latencyMS := float64(dur) / float64(time.Millisecond)
				recordPong(PingJSONPong{
					Sequence:   n,
					Time:       &pongTime,
					LatencyMS:  &latencyMS,
					Direct:     &direct,
					DERPRegion: derpRegionName,
				})

				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)
			}

			return writeResult()
		},
	}

	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. Required with --output json.",
			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),
		},
	}
	formatter.AttachOptions(&cmd.Options)
	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()
}