diff --git a/cli/ping.go b/cli/ping.go new file mode 100644 index 0000000000000..09cdca42747dc --- /dev/null +++ b/cli/ping.go @@ -0,0 +1,138 @@ +package cli + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" + + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" +) + +func ping() *cobra.Command { + var ( + pingNum int + pingTimeout time.Duration + pingWait time.Duration + verbose bool + ) + cmd := &cobra.Command{ + Annotations: workspaceCommand, + Use: "ping ", + Short: "Ping a workspace", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithCancel(cmd.Context()) + defer cancel() + + client, err := CreateClient(cmd) + if err != nil { + return err + } + + workspaceName := args[0] + _, workspaceAgent, err := getWorkspaceAndAgent(ctx, cmd, client, codersdk.Me, workspaceName, false) + if err != nil { + return err + } + + var logger slog.Logger + if verbose { + logger = slog.Make(sloghuman.Sink(cmd.OutOrStdout())).Leveled(slog.LevelDebug) + } + + conn, err := client.DialWorkspaceAgent(ctx, workspaceAgent.ID, &codersdk.DialWorkspaceAgentOptions{Logger: logger}) + if err != nil { + return err + } + defer conn.Close() + + derpMap := conn.DERPMap() + _ = derpMap + + n := 0 + didP2p := false + start := time.Now() + for { + if n > 0 { + time.Sleep(time.Second) + } + n++ + + ctx, cancel := context.WithTimeout(ctx, pingTimeout) + dur, p2p, pong, err := conn.Ping(ctx) + cancel() + if err != nil { + if xerrors.Is(err, context.DeadlineExceeded) { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "ping to %q timed out \n", workspaceName) + if n == pingNum { + return nil + } + continue + } + if xerrors.Is(err, context.Canceled) { + return nil + } + + if err.Error() == "no matching peer" { + continue + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "ping to %q failed %s\n", workspaceName, err.Error()) + if n == pingNum { + return nil + } + continue + } + + dur = dur.Round(time.Millisecond) + var via string + if p2p { + if !didP2p { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "p2p connection established in", + cliui.Styles.DateTimeStamp.Render(time.Since(start).Round(time.Millisecond).String()), + ) + } + didP2p = true + + via = fmt.Sprintf("%s via %s", + cliui.Styles.Fuchsia.Render("p2p"), + cliui.Styles.Code.Render(pong.Endpoint), + ) + } else { + derpName := "unknown" + derpRegion, ok := derpMap.Regions[pong.DERPRegionID] + if ok { + derpName = derpRegion.RegionName + } + via = fmt.Sprintf("%s via %s", + cliui.Styles.Fuchsia.Render("proxied"), + cliui.Styles.Code.Render(fmt.Sprintf("DERP(%s)", derpName)), + ) + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "pong from %s %s in %s\n", + cliui.Styles.Keyword.Render(workspaceName), + via, + cliui.Styles.DateTimeStamp.Render(dur.String()), + ) + + if n == pingNum { + return nil + } + } + }, + } + + cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enables verbose logging.") + cmd.Flags().DurationVarP(&pingWait, "wait", "", time.Second, "Specifies how long to wait between pings.") + cmd.Flags().DurationVarP(&pingTimeout, "timeout", "t", 5*time.Second, "Specifies how long to wait for a ping to complete.") + cmd.Flags().IntVarP(&pingNum, "num", "n", 10, "Specifies the number of pings to perform.") + return cmd +} diff --git a/cli/ping_test.go b/cli/ping_test.go new file mode 100644 index 0000000000000..f599e38b4cd8c --- /dev/null +++ b/cli/ping_test.go @@ -0,0 +1,54 @@ +package cli_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "cdr.dev/slog/sloggers/slogtest" + + "github.com/coder/coder/agent" + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/codersdk/agentsdk" + "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/testutil" +) + +func TestPing(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client, workspace, agentToken := setupWorkspaceForAgent(t, nil) + cmd, root := clitest.New(t, "ping", workspace.Name) + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetErr(pty.Output()) + cmd.SetOut(pty.Output()) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(agentToken) + agentCloser := agent.New(agent.Options{ + Client: agentClient, + Logger: slogtest.Make(t, nil).Named("agent"), + }) + defer func() { + _ = agentCloser.Close() + }() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + cmdDone := tGo(t, func() { + err := cmd.ExecuteContext(ctx) + assert.NoError(t, err) + }) + + pty.ExpectMatch("pong from " + workspace.Name) + cancel() + <-cmdDone + }) +} diff --git a/cli/root.go b/cli/root.go index 76cc965d18735..19ac241e29780 100644 --- a/cli/root.go +++ b/cli/root.go @@ -85,10 +85,12 @@ func Core() []*cobra.Command { login(), logout(), parameters(), + ping(), portForward(), publickey(), rename(), resetPassword(), + restart(), scaletest(), schedules(), show(), @@ -97,7 +99,6 @@ func Core() []*cobra.Command { start(), state(), stop(), - restart(), templates(), tokens(), update(), diff --git a/cli/speedtest.go b/cli/speedtest.go index 7b4d29cfeb789..6dd0f10c4ea28 100644 --- a/cli/speedtest.go +++ b/cli/speedtest.go @@ -71,7 +71,7 @@ func speedtest() *cobra.Command { return ctx.Err() case <-ticker.C: } - dur, p2p, err := conn.Ping(ctx) + dur, p2p, _, err := conn.Ping(ctx) if err != nil { continue } diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index 41c7a97978cfc..6881d4320f098 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -36,6 +36,7 @@ Workspace Commands: create Create a workspace delete Delete a workspace list List workspaces + ping Ping a workspace rename Rename a workspace restart Restart a workspace schedule Schedule automated start and stop times for workspaces diff --git a/cli/testdata/coder_ping_--help.golden b/cli/testdata/coder_ping_--help.golden new file mode 100644 index 0000000000000..430d561bda19a --- /dev/null +++ b/cli/testdata/coder_ping_--help.golden @@ -0,0 +1,26 @@ +Ping a workspace + +Usage: + coder ping [flags] + +Flags: + -h, --help help for ping + -n, --num int Specifies the number of pings to perform. (default 10) + -t, --timeout duration Specifies how long to wait for a ping to complete. (default 5s) + -v, --verbose Enables verbose logging. + --wait duration Specifies how long to wait between pings. (default 1s) + +Global Flags: + --global-config coder Path to the global coder config directory. + Consumes $CODER_CONFIG_DIR (default "~/.config/coderv2") + --header stringArray HTTP headers added to all requests. Provide as "Key=Value". + Consumes $CODER_HEADER + --no-feature-warning Suppress warnings about unlicensed features. + Consumes $CODER_NO_FEATURE_WARNING + --no-version-warning Suppress warning when client and server versions do not match. + Consumes $CODER_NO_VERSION_WARNING + --token string Specify an authentication token. For security reasons setting + CODER_SESSION_TOKEN is preferred. + Consumes $CODER_SESSION_TOKEN + --url string URL to a deployment. + Consumes $CODER_URL diff --git a/cli/vscodessh.go b/cli/vscodessh.go index eef217db509a3..e6c3f83034b83 100644 --- a/cli/vscodessh.go +++ b/cli/vscodessh.go @@ -204,7 +204,7 @@ type sshNetworkStats struct { } func collectNetworkStats(ctx context.Context, agentConn *codersdk.WorkspaceAgentConn, start, end time.Time, counts map[netlogtype.Connection]netlogtype.Counts) (*sshNetworkStats, error) { - latency, p2p, err := agentConn.Ping(ctx) + latency, p2p, _, err := agentConn.Ping(ctx) if err != nil { return nil, err } diff --git a/codersdk/workspaceagentconn.go b/codersdk/workspaceagentconn.go index d6a4feffa37d5..a9898592d4bb1 100644 --- a/codersdk/workspaceagentconn.go +++ b/codersdk/workspaceagentconn.go @@ -17,6 +17,7 @@ import ( "github.com/google/uuid" "golang.org/x/crypto/ssh" "golang.org/x/xerrors" + "tailscale.com/ipn/ipnstate" "tailscale.com/net/speedtest" "github.com/coder/coder/coderd/tracing" @@ -136,7 +137,7 @@ func (c *WorkspaceAgentConn) AwaitReachable(ctx context.Context) bool { // Ping pings the agent and returns the round-trip time. // The bool returns true if the ping was made P2P. -func (c *WorkspaceAgentConn) Ping(ctx context.Context) (time.Duration, bool, error) { +func (c *WorkspaceAgentConn) Ping(ctx context.Context) (time.Duration, bool, *ipnstate.PingResult, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() diff --git a/docs/cli/coder.md b/docs/cli/coder.md index c6797282cddc8..f576b7c37344a 100644 --- a/docs/cli/coder.md +++ b/docs/cli/coder.md @@ -49,6 +49,7 @@ coder [flags] - [coder list](coder_list.md) - List workspaces - [coder login](coder_login.md) - Authenticate with Coder deployment - [coder logout](coder_logout.md) - Unauthenticate your local session +- [coder ping](coder_ping.md) - Ping a workspace - [coder port-forward](coder_port-forward.md) - Forward ports from machine to a workspace - [coder publickey](coder_publickey.md) - Output your Coder public key used for Git operations - [coder rename](coder_rename.md) - Rename a workspace diff --git a/docs/cli/coder_ping.md b/docs/cli/coder_ping.md new file mode 100644 index 0000000000000..04d222473d462 --- /dev/null +++ b/docs/cli/coder_ping.md @@ -0,0 +1,38 @@ +## coder ping + +Ping a workspace + +``` +coder ping [flags] +``` + +### Options + +``` + -h, --help help for ping + -n, --num int Specifies the number of pings to perform. (default 10) + -t, --timeout duration Specifies how long to wait for a ping to complete. (default 5s) + -v, --verbose Enables verbose logging. + --wait duration Specifies how long to wait between pings. (default 1s) +``` + +### Options inherited from parent commands + +``` + --global-config coder Path to the global coder config directory. + Consumes $CODER_CONFIG_DIR (default "~/.config/coderv2") + --header stringArray HTTP headers added to all requests. Provide as "Key=Value". + Consumes $CODER_HEADER + --no-feature-warning Suppress warnings about unlicensed features. + Consumes $CODER_NO_FEATURE_WARNING + --no-version-warning Suppress warning when client and server versions do not match. + Consumes $CODER_NO_VERSION_WARNING + --token string Specify an authentication token. For security reasons setting CODER_SESSION_TOKEN is preferred. + Consumes $CODER_SESSION_TOKEN + --url string URL to a deployment. + Consumes $CODER_URL +``` + +### SEE ALSO + +- [coder](coder.md) - diff --git a/docs/manifest.json b/docs/manifest.json index ecfff14d19bb5..99f98817269d3 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -453,6 +453,10 @@ "title": "logout", "path": "./cli/coder_logout.md" }, + { + "title": "ping", + "path": "./cli/coder_ping.md" + }, { "title": "port-forward", "path": "./cli/coder_port-forward.md" diff --git a/enterprise/coderd/replicas_test.go b/enterprise/coderd/replicas_test.go index 4e910ac84a56c..50eac66392940 100644 --- a/enterprise/coderd/replicas_test.go +++ b/enterprise/coderd/replicas_test.go @@ -84,7 +84,7 @@ func TestReplicas(t *testing.T) { require.Eventually(t, func() bool { ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancelFunc() - _, _, err = conn.Ping(ctx) + _, _, _, err = conn.Ping(ctx) return err == nil }, testutil.WaitLong, testutil.IntervalFast) _ = conn.Close() @@ -129,7 +129,7 @@ func TestReplicas(t *testing.T) { require.Eventually(t, func() bool { ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.IntervalSlow) defer cancelFunc() - _, _, err = conn.Ping(ctx) + _, _, _, err = conn.Ping(ctx) return err == nil }, testutil.WaitLong, testutil.IntervalFast) _ = conn.Close() diff --git a/scaletest/agentconn/run.go b/scaletest/agentconn/run.go index 9ce2bb16922f0..02f9fc66a1b71 100644 --- a/scaletest/agentconn/run.go +++ b/scaletest/agentconn/run.go @@ -141,7 +141,7 @@ func waitForDisco(ctx context.Context, logs io.Writer, conn *codersdk.WorkspaceA for i := 0; i < pingAttempts; i++ { _, _ = fmt.Fprintf(logs, "\tDisco ping attempt %d/%d...\n", i+1, pingAttempts) pingCtx, cancel := context.WithTimeout(ctx, defaultRequestTimeout) - _, p2p, err := conn.Ping(pingCtx) + _, p2p, _, err := conn.Ping(pingCtx) cancel() if err == nil { _, _ = fmt.Fprintf(logs, "\tDisco ping succeeded after %d attempts, p2p = %v\n", i+1, p2p) diff --git a/tailnet/conn.go b/tailnet/conn.go index 80ede1184f131..bd1c352d5e82c 100644 --- a/tailnet/conn.go +++ b/tailnet/conn.go @@ -432,7 +432,7 @@ func (c *Conn) Status() *ipnstate.Status { // Ping sends a Disco ping to the Wireguard engine. // The bool returned is true if the ping was performed P2P. -func (c *Conn) Ping(ctx context.Context, ip netip.Addr) (time.Duration, bool, error) { +func (c *Conn) Ping(ctx context.Context, ip netip.Addr) (time.Duration, bool, *ipnstate.PingResult, error) { errCh := make(chan error, 1) prChan := make(chan *ipnstate.PingResult, 1) go c.wireguardEngine.Ping(ip, tailcfg.PingDisco, func(pr *ipnstate.PingResult) { @@ -444,11 +444,11 @@ func (c *Conn) Ping(ctx context.Context, ip netip.Addr) (time.Duration, bool, er }) select { case err := <-errCh: - return 0, false, err + return 0, false, nil, err case <-ctx.Done(): - return 0, false, ctx.Err() + return 0, false, nil, ctx.Err() case pr := <-prChan: - return time.Duration(pr.LatencySeconds * float64(time.Second)), pr.Endpoint != "", nil + return time.Duration(pr.LatencySeconds * float64(time.Second)), pr.Endpoint != "", pr, nil } } @@ -477,7 +477,7 @@ func (c *Conn) AwaitReachable(ctx context.Context, ip netip.Addr) bool { ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) defer cancel() - _, _, err := c.Ping(ctx, ip) + _, _, _, err := c.Ping(ctx, ip) if err == nil { completed() }