Skip to content
This repository was archived by the owner on Aug 30, 2024. It is now read-only.

Commit 9244ee8

Browse files
authored
feat: Add Ping command to monitor workspace latency (#409)
* feat: Add Ping command to monitor workspace latency * Handle shut off with nice error * Organize funcs * Add docs * Organize imports * Move to subdommand of workspaces * Refactor to be smarter * Enable scheme filtering * Add count flag * Fix import order * Disable linting for nested if * Generate docs * Extract funcs * Update docs * Remove receiver
1 parent 3536869 commit 9244ee8

File tree

5 files changed

+269
-19
lines changed

5 files changed

+269
-19
lines changed

docs/coder_workspaces.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Perform operations on the Coder workspaces owned by the active user.
2626
* [coder workspaces edit](coder_workspaces_edit.md) - edit an existing workspace and initiate a rebuild.
2727
* [coder workspaces edit-from-config](coder_workspaces_edit-from-config.md) - change the template a workspace is tracking
2828
* [coder workspaces ls](coder_workspaces_ls.md) - list all workspaces owned by the active user
29+
* [coder workspaces ping](coder_workspaces_ping.md) - ping Coder workspaces by name
2930
* [coder workspaces policy-template](coder_workspaces_policy-template.md) - Set workspace policy template
3031
* [coder workspaces rebuild](coder_workspaces_rebuild.md) - rebuild a Coder workspace
3132
* [coder workspaces rm](coder_workspaces_rm.md) - remove Coder workspaces by name

docs/coder_workspaces_ping.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
## coder workspaces ping
2+
3+
ping Coder workspaces by name
4+
5+
### Synopsis
6+
7+
ping Coder workspaces by name
8+
9+
```
10+
coder workspaces ping <workspace_name> [flags]
11+
```
12+
13+
### Examples
14+
15+
```
16+
coder workspaces ping front-end-workspace
17+
```
18+
19+
### Options
20+
21+
```
22+
-c, --count int stop after <count> replies
23+
-h, --help help for ping
24+
-s, --scheme strings customize schemes to filter ice servers (default [stun,stuns,turn,turns])
25+
```
26+
27+
### Options inherited from parent commands
28+
29+
```
30+
-v, --verbose show verbose output
31+
```
32+
33+
### SEE ALSO
34+
35+
* [coder workspaces](coder_workspaces.md) - Interact with Coder workspaces
36+

internal/cmd/cmd.go

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,25 +22,25 @@ func Make() *cobra.Command {
2222
}
2323

2424
app.AddCommand(
25+
agentCmd(),
26+
completionCmd(),
27+
configSSHCmd(),
28+
envCmd(), // DEPRECATED.
29+
genDocsCmd(app),
30+
imgsCmd(),
2531
loginCmd(),
2632
logoutCmd(),
33+
providersCmd(),
34+
resourceCmd(),
35+
satellitesCmd(),
2736
sshCmd(),
28-
usersCmd(),
29-
tagsCmd(),
30-
configSSHCmd(),
31-
envCmd(), // DEPRECATED.
32-
workspacesCmd(),
3337
syncCmd(),
34-
urlCmd(),
38+
tagsCmd(),
3539
tokensCmd(),
36-
resourceCmd(),
37-
completionCmd(),
38-
imgsCmd(),
39-
providersCmd(),
40-
genDocsCmd(app),
41-
agentCmd(),
4240
tunnelCmd(),
43-
satellitesCmd(),
41+
urlCmd(),
42+
usersCmd(),
43+
workspacesCmd(),
4444
)
4545
app.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "show verbose output")
4646
return app

internal/cmd/workspaces.go

Lines changed: 214 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,27 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"errors"
78
"fmt"
89
"io"
910
"io/ioutil"
11+
"os"
12+
"strings"
13+
"time"
14+
15+
"nhooyr.io/websocket"
1016

1117
"cdr.dev/coder-cli/coder-sdk"
1218
"cdr.dev/coder-cli/internal/coderutil"
1319
"cdr.dev/coder-cli/internal/x/xcobra"
1420
"cdr.dev/coder-cli/pkg/clog"
1521
"cdr.dev/coder-cli/pkg/tablewriter"
22+
"cdr.dev/coder-cli/wsnet"
1623

24+
"github.com/fatih/color"
1725
"github.com/manifoldco/promptui"
26+
"github.com/pion/ice/v2"
27+
"github.com/pion/webrtc/v3"
1828
"github.com/spf13/cobra"
1929
"golang.org/x/xerrors"
2030
)
@@ -38,16 +48,17 @@ func workspacesCmd() *cobra.Command {
3848
}
3949

4050
cmd.AddCommand(
51+
createWorkspaceCmd(),
52+
editWorkspaceCmd(),
4153
lsWorkspacesCommand(),
42-
stopWorkspacesCmd(),
54+
pingWorkspaceCommand(),
55+
rebuildWorkspaceCommand(),
4356
rmWorkspacesCmd(),
57+
setPolicyTemplate(),
58+
stopWorkspacesCmd(),
4459
watchBuildLogCommand(),
45-
rebuildWorkspaceCommand(),
46-
createWorkspaceCmd(),
47-
workspaceFromConfigCmd(true),
4860
workspaceFromConfigCmd(false),
49-
editWorkspaceCmd(),
50-
setPolicyTemplate(),
61+
workspaceFromConfigCmd(true),
5162
)
5263
return cmd
5364
}
@@ -120,6 +131,203 @@ func lsWorkspacesCommand() *cobra.Command {
120131
return cmd
121132
}
122133

134+
func pingWorkspaceCommand() *cobra.Command {
135+
var (
136+
schemes []string
137+
count int
138+
)
139+
140+
cmd := &cobra.Command{
141+
Use: "ping <workspace_name>",
142+
Short: "ping Coder workspaces by name",
143+
Long: "ping Coder workspaces by name",
144+
Example: `coder workspaces ping front-end-workspace`,
145+
Args: xcobra.ExactArgs(1),
146+
RunE: func(cmd *cobra.Command, args []string) error {
147+
ctx := cmd.Context()
148+
client, err := newClient(ctx, true)
149+
if err != nil {
150+
return err
151+
}
152+
workspace, err := findWorkspace(ctx, client, args[0], coder.Me)
153+
if err != nil {
154+
return err
155+
}
156+
157+
iceSchemes := map[ice.SchemeType]interface{}{}
158+
for _, rawScheme := range schemes {
159+
scheme := ice.NewSchemeType(rawScheme)
160+
if scheme == ice.Unknown {
161+
return fmt.Errorf("scheme type %q not recognized", rawScheme)
162+
}
163+
iceSchemes[scheme] = nil
164+
}
165+
166+
pinger := &wsPinger{
167+
client: client,
168+
workspace: workspace,
169+
iceSchemes: iceSchemes,
170+
}
171+
172+
seq := 0
173+
ticker := time.NewTicker(time.Second)
174+
for {
175+
select {
176+
case <-ticker.C:
177+
err := pinger.ping(ctx)
178+
if err != nil {
179+
return err
180+
}
181+
seq++
182+
if count > 0 && seq >= count {
183+
os.Exit(0)
184+
}
185+
case <-ctx.Done():
186+
return nil
187+
}
188+
}
189+
},
190+
}
191+
192+
cmd.Flags().StringSliceVarP(&schemes, "scheme", "s", []string{"stun", "stuns", "turn", "turns"}, "customize schemes to filter ice servers")
193+
cmd.Flags().IntVarP(&count, "count", "c", 0, "stop after <count> replies")
194+
return cmd
195+
}
196+
197+
type wsPinger struct {
198+
client coder.Client
199+
workspace *coder.Workspace
200+
dialer *wsnet.Dialer
201+
iceSchemes map[ice.SchemeType]interface{}
202+
tunneled bool
203+
}
204+
205+
func (*wsPinger) logFail(msg string) {
206+
fmt.Printf("%s: %s\n", color.New(color.Bold, color.FgRed).Sprint("——"), msg)
207+
}
208+
209+
func (*wsPinger) logSuccess(timeStr, msg string) {
210+
fmt.Printf("%s: %s\n", color.New(color.Bold, color.FgGreen).Sprint(timeStr), msg)
211+
}
212+
213+
// Only return fatal errors
214+
func (w *wsPinger) ping(ctx context.Context) error {
215+
ctx, cancelFunc := context.WithTimeout(ctx, time.Second*15)
216+
defer cancelFunc()
217+
url := w.client.BaseURL()
218+
219+
// If the dialer is nil we create a new!
220+
// nolint:nestif
221+
if w.dialer == nil {
222+
servers, err := w.client.ICEServers(ctx)
223+
if err != nil {
224+
w.logFail(fmt.Sprintf("list ice servers: %s", err.Error()))
225+
return nil
226+
}
227+
filteredServers := make([]webrtc.ICEServer, 0, len(servers))
228+
for _, server := range servers {
229+
good := true
230+
for _, rawURL := range server.URLs {
231+
url, err := ice.ParseURL(rawURL)
232+
if err != nil {
233+
return fmt.Errorf("parse url %q: %w", rawURL, err)
234+
}
235+
if _, ok := w.iceSchemes[url.Scheme]; !ok {
236+
good = false
237+
}
238+
}
239+
if good {
240+
filteredServers = append(filteredServers, server)
241+
}
242+
}
243+
if len(filteredServers) == 0 {
244+
schemes := make([]string, 0)
245+
for scheme := range w.iceSchemes {
246+
schemes = append(schemes, scheme.String())
247+
}
248+
return fmt.Errorf("no ice servers match the schemes provided: %s", strings.Join(schemes, ","))
249+
}
250+
workspace, err := w.client.WorkspaceByID(ctx, w.workspace.ID)
251+
if err != nil {
252+
return err
253+
}
254+
if workspace.LatestStat.ContainerStatus != coder.WorkspaceOn {
255+
w.logFail(fmt.Sprintf("workspace is unreachable (status=%s)", workspace.LatestStat.ContainerStatus))
256+
return nil
257+
}
258+
connectStart := time.Now()
259+
w.dialer, err = wsnet.DialWebsocket(ctx, wsnet.ConnectEndpoint(&url, w.workspace.ID, w.client.Token()), &wsnet.DialOptions{
260+
ICEServers: filteredServers,
261+
TURNProxyAuthToken: w.client.Token(),
262+
TURNRemoteProxyURL: &url,
263+
TURNLocalProxyURL: &url,
264+
}, &websocket.DialOptions{})
265+
if err != nil {
266+
w.logFail(fmt.Sprintf("dial workspace: %s", err.Error()))
267+
return nil
268+
}
269+
connectMS := float64(time.Since(connectStart).Microseconds()) / 1000
270+
271+
candidates, err := w.dialer.Candidates()
272+
if err != nil {
273+
return err
274+
}
275+
isRelaying := candidates.Local.Typ == webrtc.ICECandidateTypeRelay
276+
w.tunneled = false
277+
candidateURLs := []string{}
278+
279+
for _, server := range filteredServers {
280+
if server.Username == wsnet.TURNProxyICECandidate().Username {
281+
candidateURLs = append(candidateURLs, fmt.Sprintf("turn:%s", url.Host))
282+
if !isRelaying {
283+
continue
284+
}
285+
w.tunneled = true
286+
continue
287+
}
288+
289+
candidateURLs = append(candidateURLs, server.URLs...)
290+
}
291+
292+
connectionText := "direct via STUN"
293+
if isRelaying {
294+
connectionText = "proxied via TURN"
295+
}
296+
if w.tunneled {
297+
connectionText = fmt.Sprintf("proxied via %s", url.Host)
298+
}
299+
w.logSuccess("——", fmt.Sprintf(
300+
"connected in %.2fms (%s) candidates=%s",
301+
connectMS,
302+
connectionText,
303+
strings.Join(candidateURLs, ","),
304+
))
305+
}
306+
307+
pingStart := time.Now()
308+
err := w.dialer.Ping(ctx)
309+
if err != nil {
310+
if errors.Is(err, io.EOF) {
311+
w.dialer = nil
312+
w.logFail("connection timed out")
313+
return nil
314+
}
315+
if errors.Is(err, webrtc.ErrConnectionClosed) {
316+
w.dialer = nil
317+
w.logFail("webrtc connection is closed")
318+
return nil
319+
}
320+
return fmt.Errorf("ping workspace: %w", err)
321+
}
322+
pingMS := float64(time.Since(pingStart).Microseconds()) / 1000
323+
connectionText := "you ↔ workspace"
324+
if w.tunneled {
325+
connectionText = fmt.Sprintf("you ↔ %s ↔ workspace", url.Host)
326+
}
327+
w.logSuccess(fmt.Sprintf("%.2fms", pingMS), connectionText)
328+
return nil
329+
}
330+
123331
func stopWorkspacesCmd() *cobra.Command {
124332
var user string
125333
cmd := &cobra.Command{

wsnet/dial.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,11 @@ func (d *Dialer) activeConnections() int {
301301
return int(stats.DataChannelsRequested-stats.DataChannelsClosed) - 1
302302
}
303303

304+
// Candidates returns the candidate pair that was chosen for the connection.
305+
func (d *Dialer) Candidates() (*webrtc.ICECandidatePair, error) {
306+
return d.rtc.SCTP().Transport().ICETransport().GetSelectedCandidatePair()
307+
}
308+
304309
// Close closes the RTC connection.
305310
// All data channels dialed will be closed.
306311
func (d *Dialer) Close() error {

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy