8000 feat(cli): add `coder open vscode` by mafredri · Pull Request #11191 · coder/coder · GitHub
[go: up one dir, main page]

Skip to content

feat(cli): add coder open vscode #11191

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Jan 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -926,7 +926,7 @@ func TestAgent_EnvironmentVariableExpansion(t *testing.T) {
func TestAgent_CoderEnvVars(t *testing.T) {
t.Parallel()

for _, key := range []string{"CODER"} {
for _, key := range []string{"CODER", "CODER_WORKSPACE_NAME", "CODER_WORKSPACE_AGENT_NAME"} {
key := key
t.Run(key, func(t *testing.T) {
t.Parallel()
Expand Down Expand Up @@ -2015,6 +2015,12 @@ func setupAgent(t *testing.T, metadata agentsdk.Manifest, ptyTimeout time.Durati
if metadata.AgentID == uuid.Nil {
metadata.AgentID = uuid.New()
}
if metadata.AgentName == "" {
metadata.AgentName = "test-agent"
}
if metadata.WorkspaceName == "" {
metadata.WorkspaceName = "test-workspace"
}
coordinator := tailnet.NewCoordinator(logger)
t.Cleanup(func() {
_ = coordinator.Close()
Expand Down
2 changes: 2 additions & 0 deletions agent/agentssh/agentssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,8 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string)
// Set environment variables reliable detection of being inside a
// Coder workspace.
cmd.Env = append(cmd.Env, "CODER=true")
cmd.Env = append(cmd.Env, "CODER_WORKSPACE_NAME="+manifest.WorkspaceName)
cmd.Env = append(cmd.Env, "CODER_WORKSPACE_AGENT_NAME="+manifest.AgentName)
cmd.Env = append(cmd.Env, fmt.Sprintf("USER=%s", username))
// Git on Windows resolves with UNIX-style paths.
// If using backslashes, it's unable to find the executable.
Expand Down
569 changes: 295 additions & 274 deletions agent/proto/agent.pb.go

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions agent/proto/agent.proto
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,10 @@ message WorkspaceAgentMetadata {

message Manifest {
bytes agent_id = 1;
string agent_name = 15;
string owner_username = 13;
bytes workspace_id = 14;
string workspace_name = 16;
uint32 git_auth_configs = 2;
map<string, string> environment_variables = 3;
string directory = 4;
Expand Down
336 changes: 336 additions & 0 deletions cli/open.go
< 8000 td class="blob-code blob-code-addition js-file-line"> package cli
Original file line number Diff line number Diff line change
@@ -0,0 +1,336 @@

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/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
)

func (r *RootCmd) open() *clibase.Cmd {
cmd := &clibase.Cmd{
Use: "open",
Short: "Open a workspace",
Handler: func(inv *clibase.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*clibase.Cmd{
r.openVSCode(),
},
}
return cmd
}

const vscodeDesktopName = "VS Code Desktop"

func (r *RootCmd) openVSCode() *clibase.Cmd {
var (
generateToken bool
testOpenError bool
)

client := new(codersdk.Client)
cmd := &clibase.Cmd{
Annotations: workspaceCommand,
Use: "vscode <workspace> [<directory in workspace>]",
Short: fmt.Sprintf("Open a workspace in %s", vscodeDesktopName),
Middleware: clibase.Chain(
clibase.RequireRangeArgs(1, 2),
r.InitClient(client),
),
Handler: func(inv *clibase.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, codersdk.Me, 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 = clibase.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: clibase.BoolOf(&generateToken),
},
{
Flag: "test.open-error",
Description: "Don't run the open command.",
Value: clibase.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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried /path/to/something, \path\to\something and C:/path/to/something which also both work in VSCode. Defaults to the drive Windows is installed on if there isn't a drive letter.

IDK how paths work in Windows containers either, unsure if they have drive letters or not.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'll need to play around with this some more. I was actually planning on testing with agents on Windows today, but it turns out our win template is broken atm. I know at least the "expanded directory" resolves to C:\... via test runners. I suspected / might work in VS Code but out of precaution I wanted to transform them.

Perhaps we can just make everything slash and only have the logic for detecting abs path on Windows.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added some handling for the cases you mentioned, and relaxed the abs requirement to include /, \. I know there are more exceptions like \\.UNC\ paths and the like, but I don't know if it's worth adding preemptive handling for that.

Copy link
Member Author
@mafredri mafredri Dec 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I can't seem to make CI happy because Go filepath.IsAbs on windows seems to think differently? https://github.com/coder/coder/actions/runs/7262677715/job/19786347550?pr=11191#step:5:496

(I kept the fallback to filepath vs our impl. on Windows so that we could catch these types of inconsistencies, but I can just remove that if we don't care.)

}
}

// 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
}
}
Loading
0