-
Notifications
You must be signed in to change notification settings - Fork 24
feat: coder connect integration #482
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
Changes from 13 commits
95b5b4e
a65e550
2ecf1df
fb9a263
3a77138
2a3500e
9252fff
195151a
e7cad82
ea4b179
5e4e795
c3287eb
a2df5cc
feb1021
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,11 +2,11 @@ import { Api } from "coder/site/src/api/api" | |
import { getErrorMessage } from "coder/site/src/api/errors" | ||
import { User, Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated" | ||
import * as vscode from "vscode" | ||
import { makeCoderSdk, needToken } from "./api" | ||
import { fetchSSHConfig, makeCoderSdk, needToken } from "./api" | ||
import { extractAgents } from "./api-helper" | ||
import { CertificateError } from "./error" | ||
import { Storage } from "./storage" | ||
import { toRemoteAuthority, toSafeHost } from "./util" | ||
import { maybeCoderConnectAddr, toRemoteAuthority, toSafeHost } from "./util" | ||
import { OpenableTreeItem } from "./workspacesProvider" | ||
|
||
export class Commands { | ||
|
@@ -392,7 +392,7 @@ export class Commands { | |
if (!baseUrl) { | ||
throw new Error("You are not logged in") | ||
} | ||
await openWorkspace( | ||
await this.openWorkspace( | ||
baseUrl, | ||
treeItem.workspaceOwner, | ||
treeItem.workspaceName, | ||
|
@@ -491,12 +491,12 @@ export class Commands { | |
} else { | ||
workspaceOwner = args[0] as string | ||
workspaceName = args[1] as string | ||
// workspaceAgent is reserved for args[2], but multiple agents aren't supported yet. | ||
workspaceAgent = args[2] as string | undefined | ||
folderPath = args[3] as string | undefined | ||
openRecent = args[4] as boolean | undefined | ||
} | ||
|
||
await openWorkspace(baseUrl, workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent) | ||
await this.openWorkspace(baseUrl, workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent) | ||
} | ||
|
||
/** | ||
|
@@ -512,11 +512,18 @@ export class Commands { | |
|
||
const workspaceOwner = args[0] as string | ||
const workspaceName = args[1] as string | ||
const workspaceAgent = undefined // args[2] is reserved, but we do not support multiple agents yet. | ||
const workspaceAgent = args[2] as string | undefined | ||
const devContainerName = args[3] as string | ||
const devContainerFolder = args[4] as string | ||
|
||
await openDevContainer(baseUrl, workspaceOwner, workspaceName, workspaceAgent, devContainerName, devContainerFolder) | ||
await this.openDevContainerInner( | ||
baseUrl, | ||
workspaceOwner, | ||
workspaceName, | ||
workspaceAgent, | ||
devContainerName, | ||
devContainerFolder, | ||
) | ||
} | ||
|
||
/** | ||
|
@@ -540,107 +547,167 @@ export class Commands { | |
await this.workspaceRestClient.updateWorkspaceVersion(this.workspace) | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Given a workspace, build the host name, find a directory to open, and pass | ||
* both to the Remote SSH plugin in the form of a remote authority URI. | ||
*/ | ||
async function openWorkspace( | ||
baseUrl: string, | ||
workspaceOwner: string, | ||
workspaceName: string, | ||
workspaceAgent: string | undefined, | ||
folderPath: string | undefined, | ||
openRecent: boolean | undefined, | ||
) { | ||
// A workspace can have multiple agents, but that's handled | ||
// when opening a workspace unless explicitly specified. | ||
const remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent) | ||
|
||
let newWindow = true | ||
// Open in the existing window if no workspaces are open. | ||
if (!vscode.workspace.workspaceFolders?.length) { | ||
newWindow = false | ||
} | ||
/** | ||
* Given a workspace, build the host name, find a directory to open, and pass | ||
* both to the Remote SSH plugin in the form of a remote authority URI. | ||
*/ | ||
private async openWorkspace( | ||
baseUrl: string, | ||
workspaceOwner: string, | ||
workspaceName: string, | ||
workspaceAgent: string | undefined, | ||
folderPath: string | undefined, | ||
openRecent: boolean | undefined, | ||
) { | ||
let remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent) | ||
|
||
// We can't connect using Coder Connect straightaway if `workspaceAgent` | ||
// is undefined. This happens when: | ||
// 1. The workspace is stopped | ||
// 2. A `vscode://coder.coder-remote` URI does not include the agent as a | ||
// query parameter. | ||
// | ||
// For 1. `Remote.setup` will migrate us to Coder Connect once the workspace is built. | ||
// For 2. `Remote.setup` will call `maybeAskAgent` and then migrate us to Coder Connect | ||
if (workspaceAgent) { | ||
let sshConfig | ||
try { | ||
// Fetch (or get defaults) for the SSH config. | ||
sshConfig = await fetchSSHConfig(this.restClient, this.vscodeProposed) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The extension has an SSH config section which I assume is not going apply to Coder Connect, is that right? As well as anything the user has added to their SSH config manually. Wondering if we should update the setting description to say the variables will not apply if there is a Coder Connect session we can use. Speaking of settings, should using Coder Connect be behind a setting as well? Or a prompt? "We see Coder Connect, do you want to use that?" or similar. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Will add.
These will apply, as long as they're on the Coder Connect hostname (so, if they just specify those options when running
I don't think this is necessary, I can't imagine a scenario where the user would have Coder Connect on and not want to use it? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I agree. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the tricky part is that the user might not realize it will use Connect. So, they have So there should be clear messaging what the plugin is going to do IMO, somewhere. Silently changing behavior feels bad to me. But, I am neither a VS Code nor Connect user, so take what I say with a grain of salt. 😆 Maybe the behavior change will be obvious to users. If we could make the same SSH settings apply either way, then that would be a different story. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With the updated Though if we change the approach to what I discussed below that solves this issue too ig There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That is quite nice! |
||
} catch (error) { | ||
const message = getErrorMessage(error, "no response from the server") | ||
this.storage.writeToCoderOutputChannel(`Failed to open workspace: ${message}`) | ||
this.vscodeProposed.window.showErrorMessage("Failed to open workspace", { | ||
ethanndickson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
detail: message, | ||
modal: true, | ||
useCustom: true, | ||
}) | ||
return | ||
} | ||
|
||
// If a folder isn't specified or we have been asked to open the most recent, | ||
// we can try to open a recently opened folder/workspace. | ||
if (!folderPath || openRecent) { | ||
const output: { | ||
workspaces: { folderUri: vscode.Uri; remoteAuthority: string }[] | ||
} = await vscode.commands.executeCommand("_workbench.getRecentlyOpened") | ||
const opened = output.workspaces.filter( | ||
// Remove recents that do not belong to this connection. The remote | ||
// authority maps to a workspace or workspace/agent combination (using the | ||
// SSH host name). This means, at the moment, you can have a different | ||
// set of recents for a workspace versus workspace/agent combination, even | ||
// if that agent is the default for the workspace. | ||
(opened) => opened.folderUri?.authority === remoteAuthority, | ||
) | ||
const coderConnectAddr = await maybeCoderConnectAddr( | ||
workspaceAgent, | ||
workspaceName, | ||
workspaceOwner, | ||
sshConfig.hostname_suffix, | ||
) | ||
if (coderConnectAddr) { | ||
remoteAuthority = `ssh-remote+${coderConnectAddr}` | ||
} | ||
} | ||
|
||
// openRecent will always use the most recent. Otherwise, if there are | ||
// multiple we ask the user which to use. | ||
if (opened.length === 1 || (opened.length > 1 && openRecent)) { | ||
folderPath = opened[0].folderUri.path | ||
} else if (opened.length > 1) { | ||
const items = opened.map((f) => f.folderUri.path) | ||
folderPath = await vscode.window.showQuickPick(items, { | ||
title: "Select a recently opened folder", | ||
}) | ||
if (!folderPath) { | ||
// User aborted. | ||
return | ||
let newWindow = true | ||
// Open in the existing window if no workspaces are open. | ||
if (!vscode.workspace.workspaceFolders?.length) { | ||
newWindow = false | ||
} | ||
|
||
// If a folder isn't specified or we have been asked to open the most recent, | ||
// we can try to open a recently opened folder/workspace. | ||
if (!folderPath || openRecent) { | ||
const output: { | ||
workspaces: { folderUri: vscode.Uri; remoteAuthority: string }[] | ||
} = await vscode.commands.executeCommand("_workbench.getRecentlyOpened") | ||
const opened = output.workspaces.filter( | ||
// Remove recents that do not belong to this connection. The remote | ||
// authority maps to a workspace or workspace/agent combination (using the | ||
// SSH host name). This means, at the moment, you can have a different | ||
// set of recents for a workspace versus workspace/agent combination, even | ||
// if that agent is the default for the workspace. | ||
(opened) => opened.folderUri?.authority === remoteAuthority, | ||
) | ||
|
||
// openRecent will always use the most recent. Otherwise, if there are | ||
// multiple we ask the user which to use. | ||
if (opened.length === 1 || (opened.length > 1 && openRecent)) { | ||
folderPath = opened[0].folderUri.path | ||
} else if (opened.length > 1) { | ||
const items = opened.map((f) => f.folderUri.path) | ||
folderPath = await vscode.window.showQuickPick(items, { | ||
title: "Select a recently opened folder", | ||
}) | ||
if (!folderPath) { | ||
// User aborted. | ||
return | ||
} | ||
} | ||
} | ||
|
||
if (folderPath) { | ||
await vscode.commands.executeCommand( | ||
"vscode.openFolder", | ||
vscode.Uri.from({ | ||
scheme: "vscode-remote", | ||
authority: remoteAuthority, | ||
path: folderPath, | ||
}), | ||
// Open this in a new window! | ||
newWindow, | ||
) | ||
return | ||
} | ||
|
||
// This opens the workspace without an active folder opened. | ||
await vscode.commands.executeCommand("vscode.newWindow", { | ||
remoteAuthority: remoteAuthority, | ||
reuseWindow: !newWindow, | ||
}) | ||
} | ||
|
||
if (folderPath) { | ||
private async openDevContainerInner( | ||
baseUrl: string, | ||
workspaceOwner: string, | ||
workspaceName: string, | ||
workspaceAgent: string | undefined, | ||
devContainerName: string, | ||
devContainerFolder: string, | ||
) { | ||
let remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent) | ||
|
||
if (workspaceAgent) { | ||
let sshConfig | ||
ethanndickson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
try { | ||
// Fetch (or get defaults) for the SSH config. | ||
sshConfig = await fetchSSHConfig(this.restClient, this.vscodeProposed) | ||
} catch (error) { | ||
const message = getErrorMessage(error, "no response from the server") | ||
this.storage.writeToCoderOutputChannel(`Failed to open workspace: ${message}`) | ||
this.vscodeProposed.window.showErrorMessage("Failed to open workspace", { | ||
detail: message, | ||
modal: true, | ||
useCustom: true, | ||
}) | ||
return | ||
} | ||
|
||
const coderConnectAddr = await maybeCoderConnectAddr( | ||
workspaceAgent, | ||
workspaceName, | ||
workspaceOwner, | ||
sshConfig.hostname_suffix, | ||
) | ||
if (coderConnectAddr) { | ||
remoteAuthority = `ssh-remote+${coderConnectAddr}` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably a dumb question, but this can result in multiple host names for the same workspace/agent? One for Connect, one for the fallback. Will that be potentially confusing, for example in the recents list I imagine it could show both? I know for the fallback we reopen with Connect if we can, but it does seem unfortunate to have both. Is there any world where we use Connect if possible as part of the proxy command or does it have to be in the extension itself? That would make it trivial to implement for other IDEs and would let us reuse the same host name (and I suppose it would make it work for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
It looks like Toolbox & regular Jetbrains don't use wildcard SSH configs? I think that makes implementing this on those a lot easier:
This is similar to how the new Since we use wildcard configs for the VSCode extension, using just the SSH config is pretty complicated. We'd need I assume it would look something like:
In both I think this approach is just too slow; two CLI invocations that need to init a Alternatively, we could change the portion after However, it might be a big lift to ensure each plugin knows what agent it's connecting to before calling SSH. Right now the Coder CLI handles that if it's not supplied in the remote authority slug.
But yeah, it does show both unfortunately. WDYT about the alternatives? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Toolbox does use wildcard hostnames by default, with an option to opt out. see the sample config from my system
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, we do use wildcards in Gateway and Toolbox now. I do not think we will be able to switch the hostname from out of under Toolbox like we can here in VS Code unfortunately (at least not the way it is currently implemented, maybe it can if we implement our own connector instead of using their SSH stuff but idk if that is possible). 😢 If we can implement purely in the SSH config and CLI, that would be ideal. IDK the best way to do it, but my initial thought would be to make IMO remote editing from the IDE's point of view should be more or less just There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. But, I do like the example you have as well. How slow are we talking? Some extra seconds to connect may not be the worst thing, if it reduces complexity on our end and makes it possible to support Connect with Toolbox. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not directly related but with toolbox JB uses the system ssh and support all SSH options. In gateway they had a built-in SSH implementation that didn't support all options. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We decided against making it part of
Honestly, it might be negligible on macOS and Linux. On the other hand, Spike discovered that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ahhh, fair. Was the performance cost of the bicopy measured? It does sound unfortunate. 1.5s for running IMO we have a few paths forward, ideally we figure out which one before merging since this may inform how we should do it here as well:
But also do not let me just be a blocker if we need to move forward for now haha. I do think the doubled-up host names and the SSH settings have the potential to cause some discomfort though. It occurs to me that if we had our own connection code instead of delegating to Microsoft's plugin, we could transparently use a different host name under the connection without doubling up on them. We could name the recent connections anything we wanted at that point. We could even add settings at that point too, I think?? 🤔 |
||
} | ||
} | ||
|
||
const devContainer = Buffer.from(JSON.stringify({ containerName: devContainerName }), "utf-8").toString("hex") | ||
const devContainerAuthority = `attached-container+${devContainer}@${remoteAuthority}` | ||
|
||
let newWindow = true | ||
if (!vscode.workspace.workspaceFolders?.length) { | ||
newWindow = false | ||
} | ||
|
||
await vscode.commands.executeCommand( | ||
"vscode.openFolder", | ||
vscode.Uri.from({ | ||
scheme: "vscode-remote", | ||
authority: remoteAuthority, | ||
path: folderPath, | ||
authority: devContainerAuthority, | ||
path: devContainerFolder, | ||
}), | ||
// Open this in a new window! | ||
newWindow, | ||
) | ||
return | ||
} | ||
|
||
// This opens the workspace without an active folder opened. | ||
await vscode.commands.executeCommand("vscode.newWindow", { | ||
remoteAuthority: remoteAuthority, | ||
reuseWindow: !newWindow, | ||
}) | ||
} | ||
|
||
async function openDevContainer( | ||
baseUrl: string, | ||
workspaceOwner: string, | ||
workspaceName: string, | ||
workspaceAgent: string | undefined, | ||
devContainerName: string, | ||
devContainerFolder: string, | ||
) { | ||
const remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent) | ||
|
||
const devContainer = Buffer.from(JSON.stringify({ containerName: devContainerName }), "utf-8").toString("hex") | ||
const devContainerAuthority = `attached-container+${devContainer}@${remoteAuthority}` | ||
|
||
let newWindow = true | ||
if (!vscode.workspace.workspaceFolders?.length) { | ||
newWindow = false | ||
} | ||
|
||
await vscode.commands.executeCommand( | ||
"vscode.openFolder", | ||
vscode.Uri.from({ | ||
scheme: "vscode-remote", | ||
authority: devContainerAuthority, | ||
path: devContainerFolder, | ||
}), | ||
newWindow, | ||
) | ||
} |
Uh oh!
There was an error while loading. Please reload this page.