Thanks to visit codestin.com
Credit goes to github.com

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)