Skip to content

Commit de53884

Browse files
feat(pro): reopen devpod desktop if daemon is not reachable and user tries to ssh into workspace
1 parent 1eeb838 commit de53884

File tree

7 files changed

+80
-28
lines changed

7 files changed

+80
-28
lines changed

cmd/ssh.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,12 @@ func (cmd *SSHCmd) jumpContainerTailscale(
156156
log log.Logger,
157157
) error {
158158
log.Debugf("Starting tailscale connection")
159+
160+
err := client.CheckWorkspaceReachable(ctx)
161+
if err != nil {
162+
return err
163+
}
164+
159165
toolSSHClient, sshClient, err := client.SSHClients(ctx, cmd.User)
160166
if err != nil {
161167
return err

pkg/client/client.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ type DaemonClient interface {
8181
// SSHClients returns an SSH client for the tool and one for the actual user
8282
SSHClients(ctx context.Context, user string) (*ssh.Client, *ssh.Client, error)
8383

84+
// CheckWorkspaceReachable checks if the given workspace is reachable from the current machine
85+
CheckWorkspaceReachable(ctx context.Context) error
86+
8487
// DirectTunnel forwards stdio to the workspace
8588
DirectTunnel(ctx context.Context, stdin io.Reader, stdout io.Writer) error
8689

pkg/client/clientimplementation/daemonclient/client.go

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/loft-sh/devpod/pkg/ts"
2323
"github.com/loft-sh/log"
2424
perrors "github.com/pkg/errors"
25+
"github.com/skratchdot/open-golang/open"
2526
"golang.org/x/crypto/ssh"
2627
"tailscale.com/client/tailscale"
2728
"tailscale.com/tailcfg"
@@ -123,28 +124,55 @@ func (c *client) RefreshOptions(ctx context.Context, userOptionsRaw []string, re
123124
return nil
124125
}
125126

126-
func (c *client) SSHClients(ctx context.Context, user string) (toolClient *ssh.Client, userClient *ssh.Client, err error) {
127+
func (c *client) CheckWorkspaceReachable(ctx context.Context) error {
127128
wAddr, err := c.getWorkspaceAddress()
128129
if err != nil {
129-
return nil, nil, fmt.Errorf("resolve workspace hostname: %w", err)
130+
return fmt.Errorf("resolve workspace hostname: %w", err)
130131
}
131-
132-
err = ts.WaitHostReachable(ctx, c.tsClient, wAddr, c.log)
132+
err = ts.WaitHostReachable(ctx, c.tsClient, wAddr, 5, false, c.log)
133133
if err != nil {
134134
instance, getWorkspaceErr := c.localClient.GetWorkspace(ctx, c.workspace.UID)
135+
// if we can't reach the daemon try to start the desktop app
136+
if daemon.IsDaemonNotAvailableError(getWorkspaceErr) {
137+
deeplink := fmt.Sprintf("devpod://open?workspace=%s&provider=%s&source=%s&ide=%s", c.workspace.ID, c.config.Name, c.workspace.Source.String(), c.workspace.IDE.Name)
138+
openErr := open.Run(deeplink)
139+
if openErr != nil {
140+
return getWorkspaceErr // inform user about daemon state
141+
}
142+
// give desktop app a chance to start
143+
time.Sleep(2 * time.Second)
144+
145+
// let's try again
146+
err = ts.WaitHostReachable(ctx, c.tsClient, wAddr, 20, true, c.log)
147+
if err != nil {
148+
instance, getWorkspaceErr = c.localClient.GetWorkspace(ctx, c.workspace.UID)
149+
} else {
150+
return nil
151+
}
152+
}
153+
135154
if getWorkspaceErr != nil {
136-
return nil, nil, fmt.Errorf("couldn't get workspace: %w", getWorkspaceErr)
155+
return fmt.Errorf("couldn't get workspace: %w", getWorkspaceErr)
137156
} else if instance.Status.Phase != storagev1.InstanceReady {
138-
return nil, nil, fmt.Errorf("workspace is '%s', please run `devpod up %s` to start it again", instance.Status.Phase, c.workspace.ID)
157+
return fmt.Errorf("workspace is '%s', please run `devpod up %s` to start it again", instance.Status.Phase, c.workspace.ID)
139158
} else if instance.Status.LastWorkspaceStatus != storagev1.WorkspaceStatusRunning {
140-
return nil, nil, fmt.Errorf("workspace is '%s', please run `devpod up %s` to start it again", instance.Status.LastWorkspaceStatus, c.workspace.ID)
159+
return fmt.Errorf("workspace is '%s', please run `devpod up %s` to start it again", instance.Status.LastWorkspaceStatus, c.workspace.ID)
141160
}
142161

143-
return nil, nil, fmt.Errorf("reach host: %w", err)
162+
return fmt.Errorf("reach host: %w", err)
144163
}
145164

146165
c.log.Debugf("Host %s is reachable. Proceeding with SSH session...", wAddr.Host())
147166

167+
return nil
168+
}
169+
170+
func (c *client) SSHClients(ctx context.Context, user string) (toolClient *ssh.Client, userClient *ssh.Client, err error) {
171+
wAddr, err := c.getWorkspaceAddress()
172+
if err != nil {
173+
return nil, nil, fmt.Errorf("resolve workspace hostname: %w", err)
174+
}
175+
148176
toolClient, err = ts.WaitForSSHClient(ctx, c.tsClient, wAddr.Host(), wAddr.Port(), "root", c.log)
149177
if err != nil {
150178
return nil, nil, fmt.Errorf("create SSH tool client: %w", err)

pkg/daemon/platform/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ func (c *LocalClient) doRequest(ctx context.Context, method string, path string,
149149
res, err := c.httpClient.Do(req)
150150
if err != nil {
151151
if isConnectToDaemonError(err) {
152-
return nil, daemonNotAvailableError{Err: err, Provider: c.provider}
152+
return nil, &DaemonNotAvailableError{Err: err, Provider: c.provider}
153153
}
154154

155155
return nil, err

pkg/daemon/platform/error.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,29 @@
11
package daemon
22

33
import (
4+
"errors"
45
"fmt"
56
"strings"
67

78
"github.com/loft-sh/devpod/pkg/platform/client"
89
)
910

10-
type daemonNotAvailableError struct {
11+
type DaemonNotAvailableError struct {
1112
Err error
1213
Provider string
1314
}
1415

15-
func (e daemonNotAvailableError) Error() string {
16+
func (e *DaemonNotAvailableError) Error() string {
1617
return fmt.Sprintf("The DevPod Daemon for provider %s isn't reachable. Is DevPod Desktop or `devpod pro daemon start --host=$YOUR_PRO_HOST` running? %v", e.Provider, e.Err)
1718
}
19+
func (e *DaemonNotAvailableError) Unwrap() error {
20+
return e.Err
21+
}
22+
23+
func IsDaemonNotAvailableError(err error) bool {
24+
var e *DaemonNotAvailableError
25+
return errors.As(err, &e)
26+
}
1827

1928
func IsAccessKeyNotFound(err error) bool {
2029
// we have to check against the string because the error is coming from the server

pkg/ts/util.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,7 @@ func GetURL(host string, port int) string {
4949
}
5050

5151
// WaitHostReachable polls until the given host is reachable via ts.
52-
func WaitHostReachable(ctx context.Context, lc *tailscale.LocalClient, addr Addr, log log.Logger) error {
53-
const maxRetries = 20
54-
52+
func WaitHostReachable(ctx context.Context, lc *tailscale.LocalClient, addr Addr, maxRetries int, withDelay bool, log log.Logger) error {
5553
for i := 0; i < maxRetries; i++ {
5654
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
5755
defer cancel()
@@ -61,6 +59,9 @@ func WaitHostReachable(ctx context.Context, lc *tailscale.LocalClient, addr Addr
6159
return nil // Host is reachable
6260
}
6361
log.Debugf("Host %s not reachable, retrying... (%d/%d)", addr.String(), i+1, maxRetries)
62+
if withDelay {
63+
time.Sleep(200 * time.Millisecond)
64+
}
6465

6566
select {
6667
case <-ctx.Done():

pkg/ts/workspace_server.go

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import (
2929
const (
3030
// TSPortForwardPort is the fixed port on which the workspace WebSocket reverse proxy listens.
3131
TSPortForwardPort string = "12051"
32+
33+
netMapCooldown = 30 * time.Second
3234
)
3335

3436
// WorkspaceServer holds the TSNet server and its listeners.
@@ -84,21 +86,24 @@ func (s *WorkspaceServer) Start(ctx context.Context) error {
8486
return err
8587
}
8688

87-
// debug: write the netmap to a file
88-
if os.Getenv("DEVPOD_DEBUG_DAEMON") == "true" {
89-
go func() {
90-
if err := WatchNetmap(ctx, lc, func(netMap *netmap.NetworkMap) {
91-
nm, err := json.Marshal(netMap)
92-
if err != nil {
93-
s.log.Errorf("Failed to marshal netmap: %v", err)
94-
} else {
95-
_ = os.WriteFile(filepath.Join(s.config.RootDir, "netmap.json"), nm, 0o644)
96-
}
97-
}); err != nil {
98-
s.log.Errorf("Failed to watch netmap: %v", err)
89+
go func() {
90+
lastUpdate := time.Now()
91+
if err := WatchNetmap(ctx, lc, func(netMap *netmap.NetworkMap) {
92+
if time.Since(lastUpdate) < netMapCooldown {
93+
return
9994
}
100-
}()
101-
}
95+
lastUpdate = time.Now()
96+
97+
nm, err := json.Marshal(netMap)
98+
if err != nil {
99+
s.log.Errorf("Failed to marshal netmap: %v", err)
100+
} else {
101+
_ = os.WriteFile(filepath.Join(s.config.RootDir, "netmap.json"), nm, 0o644)
102+
}
103+
}); err != nil {
104+
s.log.Errorf("Failed to watch netmap: %v", err)
105+
}
106+
}()
102107

103108
// Wait until the context is canceled.
104109
<-ctx.Done()

0 commit comments

Comments
 (0)