coder/cli/open.go

337 lines
10 KiB
Go

package cli
import (
"context"
"fmt"
"net/url"
"path"
"path/filepath"
"runtime"
"strings"
"github.com/skratchdot/open-golang/open"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (r *RootCmd) open() *serpent.Command {
cmd := &serpent.Command{
Use: "open",
Short: "Open a workspace",
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*serpent.Command{
r.openVSCode(),
},
}
return cmd
}
const vscodeDesktopName = "VS Code Desktop"
func (r *RootCmd) openVSCode() *serpent.Command {
var (
generateToken bool
testOpenError bool
)
client := new(codersdk.Client)
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "vscode <workspace> [<directory in workspace>]",
Short: fmt.Sprintf("Open a workspace in %s", vscodeDesktopName),
Middleware: serpent.Chain(
serpent.RequireRangeArgs(1, 2),
r.InitClient(client),
),
Handler: func(inv *serpent.Invocation) error {
ctx, cancel := context.WithCancel(inv.Context())
defer cancel()
// Check if we're inside a workspace, and especially inside _this_
// workspace so we can perform path resolution/expansion. Generally,
// we know that if we're inside a workspace, `open` can't be used.
insideAWorkspace := inv.Environ.Get("CODER") == "true"
inWorkspaceName := inv.Environ.Get("CODER_WORKSPACE_NAME") + "." + inv.Environ.Get("CODER_WORKSPACE_AGENT_NAME")
// We need a started workspace to figure out e.g. expanded directory.
// Pehraps the vscode-coder extension could handle this by accepting
// default_directory=true, then probing the agent. Then we wouldn't
// need to wait for the agent to start.
workspaceQuery := inv.Args[0]
autostart := true
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, autostart, workspaceQuery)
if err != nil {
return xerrors.Errorf("get workspace and agent: %w", err)
}
workspaceName := workspace.Name + "." + workspaceAgent.Name
insideThisWorkspace := insideAWorkspace && inWorkspaceName == workspaceName
if !insideThisWorkspace {
// Wait for the agent to connect, we don't care about readiness
// otherwise (e.g. wait).
err = cliui.Agent(ctx, inv.Stderr, workspaceAgent.ID, cliui.AgentOptions{
Fetch: client.WorkspaceAgent,
FetchLogs: nil,
Wait: false,
})
if err != nil {
if xerrors.Is(err, context.Canceled) {
return cliui.Canceled
}
return xerrors.Errorf("agent: %w", err)
}
// The agent will report it's expanded directory before leaving
// the created state, so we need to wait for that to happen.
// However, if no directory is set, the expanded directory will
// not be set either.
if workspaceAgent.Directory != "" {
workspace, workspaceAgent, err = waitForAgentCond(ctx, client, workspace, workspaceAgent, func(a codersdk.WorkspaceAgent) bool {
return workspaceAgent.LifecycleState != codersdk.WorkspaceAgentLifecycleCreated
})
if err != nil {
return xerrors.Errorf("wait for agent: %w", err)
}
}
}
var directory string
if len(inv.Args) > 1 {
directory = inv.Args[1]
}
directory, err = resolveAgentAbsPath(workspaceAgent.ExpandedDirectory, directory, workspaceAgent.OperatingSystem, insideThisWorkspace)
if err != nil {
return xerrors.Errorf("resolve agent path: %w", err)
}
u := &url.URL{
Scheme: "vscode",
Host: "coder.coder-remote",
Path: "/open",
}
qp := url.Values{}
qp.Add("url", client.URL.String())
qp.Add("owner", workspace.OwnerName)
qp.Add("workspace", workspace.Name)
qp.Add("agent", workspaceAgent.Name)
if directory != "" {
qp.Add("folder", directory)
}
// We always set the token if we believe we can open without
// printing the URI, otherwise the token must be explicitly
// requested as it will be printed in plain text.
if !insideAWorkspace || generateToken {
// Prepare an API key. This is for automagical configuration of
// VS Code, however, if running on a local machine we could try
// to probe VS Code settings to see if the current configuration
// is valid. Future improvement idea.
apiKey, err := client.CreateAPIKey(ctx, codersdk.Me)
if err != nil {
return xerrors.Errorf("create API key: %w", err)
}
qp.Add("token", apiKey.Key)
}
u.RawQuery = qp.Encode()
openingPath := workspaceName
if directory != "" {
openingPath += ":" + directory
}
if insideAWorkspace {
_, _ = fmt.Fprintf(inv.Stderr, "Opening %s in %s is not supported inside a workspace, please open the following URI on your local machine instead:\n\n", openingPath, vscodeDesktopName)
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", u.String())
return nil
}
_, _ = fmt.Fprintf(inv.Stderr, "Opening %s in %s\n", openingPath, vscodeDesktopName)
if !testOpenError {
err = open.Run(u.String())
} else {
err = xerrors.New("test.open-error")
}
if err != nil {
if !generateToken {
// This is not an important step, so we don't want
// to block the user here.
token := qp.Get("token")
wait := doAsync(func() {
// Best effort, we don't care if this fails.
apiKeyID := strings.SplitN(token, "-", 2)[0]
_ = client.DeleteAPIKey(ctx, codersdk.Me, apiKeyID)
})
defer wait()
qp.Del("token")
u.RawQuery = qp.Encode()
}
_, _ = fmt.Fprintf(inv.Stderr, "Could not automatically open %s in %s: %s\n", openingPath, vscodeDesktopName, err)
_, _ = fmt.Fprintf(inv.Stderr, "Please open the following URI instead:\n\n")
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", u.String())
return nil
}
return nil
},
}
cmd.Options = serpent.OptionSet{
{
Flag: "generate-token",
Env: "CODER_OPEN_VSCODE_GENERATE_TOKEN",
Description: fmt.Sprintf(
"Generate an auth token and include it in the vscode:// URI. This is for automagical configuration of %s and not needed if already configured. "+
"This flag does not need to be specified when running this command on a local machine unless automatic open fails.",
vscodeDesktopName,
),
Value: serpent.BoolOf(&generateToken),
},
{
Flag: "test.open-error",
Description: "Don't run the open command.",
Value: serpent.BoolOf(&testOpenError),
Hidden: true, // This is for testing!
},
}
return cmd
}
// waitForAgentCond uses the watch workspace API to update the agent information
// until the condition is met.
func waitForAgentCond(ctx context.Context, client *codersdk.Client, workspace codersdk.Workspace, workspaceAgent codersdk.WorkspaceAgent, cond func(codersdk.WorkspaceAgent) bool) (codersdk.Workspace, codersdk.WorkspaceAgent, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
if cond(workspaceAgent) {
return workspace, workspaceAgent, nil
}
wc, err := client.WatchWorkspace(ctx, workspace.ID)
if err != nil {
return workspace, workspaceAgent, xerrors.Errorf("watch workspace: %w", err)
}
for workspace = range wc {
workspaceAgent, err = getWorkspaceAgent(workspace, workspaceAgent.Name)
if err != nil {
return workspace, workspaceAgent, xerrors.Errorf("get workspace agent: %w", err)
}
if cond(workspaceAgent) {
return workspace, workspaceAgent, nil
}
}
return workspace, workspaceAgent, xerrors.New("watch workspace: unexpected closed channel")
}
// isWindowsAbsPath does a simplistic check for if the path is an absolute path
// on Windows. Drive letter or preceding `\` is interpreted as absolute.
func isWindowsAbsPath(p string) bool {
// Remove the drive letter, if present.
if len(p) >= 2 && p[1] == ':' {
p = p[2:]
}
switch {
case len(p) == 0:
return false
case p[0] == '\\':
return true
default:
return false
}
}
// windowsJoinPath joins the elements into a path, using Windows path separator
// and converting forward slashes to backslashes.
func windowsJoinPath(elem ...string) string {
if runtime.GOOS == "windows" {
return filepath.Join(elem...)
}
var s string
for _, e := range elem {
e = unixToWindowsPath(e)
if e == "" {
continue
}
if s == "" {
s = e
continue
}
s += "\\" + strings.TrimSuffix(e, "\\")
}
return s
}
func unixToWindowsPath(p string) string {
return strings.ReplaceAll(p, "/", "\\")
}
// resolveAgentAbsPath resolves the absolute path to a file or directory in the
// workspace. If the path is relative, it will be resolved relative to the
// workspace's expanded directory. If the path is absolute, it will be returned
// as-is. If the path is relative and the workspace directory is not expanded,
// an error will be returned.
//
// If the path is being resolved within the workspace, the path will be resolved
// relative to the current working directory.
func resolveAgentAbsPath(workingDirectory, relOrAbsPath, agentOS string, local bool) (string, error) {
switch {
case relOrAbsPath == "":
return workingDirectory, nil
case relOrAbsPath == "~" || strings.HasPrefix(relOrAbsPath, "~/"):
return "", xerrors.Errorf("path %q requires expansion and is not supported, use an absolute path instead", relOrAbsPath)
case local:
p, err := filepath.Abs(relOrAbsPath)
if err != nil {
return "", xerrors.Errorf("expand path: %w", err)
}
return p, nil
case agentOS == "windows":
relOrAbsPath = unixToWindowsPath(relOrAbsPath)
switch {
case workingDirectory != "" && !isWindowsAbsPath(relOrAbsPath):
return windowsJoinPath(workingDirectory, relOrAbsPath), nil
case isWindowsAbsPath(relOrAbsPath):
return relOrAbsPath, nil
default:
return "", xerrors.Errorf("path %q not supported, use an absolute path instead", relOrAbsPath)
}
// Note that we use `path` instead of `filepath` since we want Unix behavior.
case workingDirectory != "" && !path.IsAbs(relOrAbsPath):
return path.Join(workingDirectory, relOrAbsPath), nil
case path.IsAbs(relOrAbsPath):
return relOrAbsPath, nil
default:
return "", xerrors.Errorf("path %q not supported, use an absolute path instead", relOrAbsPath)
}
}
func doAsync(f func()) (wait func()) {
done := make(chan struct{})
go func() {
defer close(done)
f()
}()
return func() {
<-done
}
}