@@ -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+
123331func stopWorkspacesCmd () * cobra.Command {
124332 var user string
125333 cmd := & cobra.Command {
0 commit comments