From 916d7690e19a428f5e3f389255d867b9202132e4 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 29 May 2024 14:02:50 -0800 Subject: [PATCH 1/5] Convert setSessionToken to async --- src/storage.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/storage.ts b/src/storage.ts index 6c793c05..99eb7dd3 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -78,15 +78,13 @@ export class Storage { return urls.size > MAX_URLS ? Array.from(urls).slice(urls.size - MAX_URLS, urls.size) : Array.from(urls) } - public setSessionToken(sessionToken?: string): Thenable { + public async setSessionToken(sessionToken?: string): Promise { if (!sessionToken) { - return this.secrets.delete("sessionToken").then(() => { - return this.updateSessionToken() - }) + await this.secrets.delete("sessionToken") + } else { + await this.secrets.store("sessionToken", sessionToken) } - return this.secrets.store("sessionToken", sessionToken).then(() => { - return this.updateSessionToken() - }) + return this.updateSessionToken() } public async getSessionToken(): Promise { From 33af7b9d5707f2c09857849cb74a83abebf5c577 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 29 May 2024 15:46:15 -0800 Subject: [PATCH 2/5] Upgrade to latest Coder We need this to create standalone clients so we can support multiple deployments. Currently the plugin always uses the url and token for the current deployment, but you might be trying to launch a previous workspace that belongs to a different deployment (from the recents projects menu for example). Also, lots of the code relies on the url and token being stored and accessed globally (storage.getURL(), storage.getSessionToken()) so avoid those as much as possible (only use when creating a client, basically). --- src/api.ts | 61 +++++++++++++ src/commands.ts | 180 ++++++++++++++++++++++++-------------- src/extension.ts | 81 ++++------------- src/remote.ts | 159 +++++++++++++++++++-------------- src/storage.ts | 100 +++++++++++---------- src/workspaceAction.ts | 11 +-- src/workspacesProvider.ts | 35 +++++--- yarn.lock | 3 +- 8 files changed, 368 insertions(+), 262 deletions(-) create mode 100644 src/api.ts diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 00000000..7e736bc0 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,61 @@ +import { Api } from "coder/site/src/api/api" +import fs from "fs/promises" +import * as https from "https" +import * as os from "os" +import * as vscode from "vscode" +import { CertificateError } from "./error" +import { Storage } from "./storage" + +// expandPath will expand ${userHome} in the input string. +const expandPath = (input: string): string => { + const userHome = os.homedir() + return input.replace(/\${userHome}/g, userHome) +} + +/** + * Create an sdk instance using the provided URL and token and hook it up to + * configuration. The token may be undefined if some other form of + * authentication is being used. + */ +export async function makeCoderSdk(baseUrl: string, token: string | undefined, storage: Storage): Promise { + const restClient = new Api() + restClient.setHost(baseUrl) + if (token) { + restClient.setSessionToken(token) + } + + restClient.getAxiosInstance().interceptors.request.use(async (config) => { + // Add headers from the header command. + Object.entries(await storage.getHeaders(baseUrl)).forEach(([key, value]) => { + config.headers[key] = value + }) + + // Configure TLS. + const cfg = vscode.workspace.getConfiguration() + const insecure = Boolean(cfg.get("coder.insecure")) + const certFile = expandPath(String(cfg.get("coder.tlsCertFile") ?? "").trim()) + const keyFile = expandPath(String(cfg.get("coder.tlsKeyFile") ?? "").trim()) + const caFile = expandPath(String(cfg.get("coder.tlsCaFile") ?? "").trim()) + + config.httpsAgent = new https.Agent({ + cert: certFile === "" ? undefined : await fs.readFile(certFile), + key: keyFile === "" ? undefined : await fs.readFile(keyFile), + ca: caFile === "" ? undefined : await fs.readFile(caFile), + // rejectUnauthorized defaults to true, so we need to explicitly set it to false + // if we want to allow self-signed certificates. + rejectUnauthorized: !insecure, + }) + + return config + }) + + // Wrap certificate errors. + restClient.getAxiosInstance().interceptors.response.use( + (r) => r, + async (err) => { + throw await CertificateError.maybeWrap(err, baseUrl, storage) + }, + ) + + return restClient +} diff --git a/src/commands.ts b/src/commands.ts index 99f5e930..a34ac85d 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,6 +1,6 @@ -import axios from "axios" -import { getAuthenticatedUser, getWorkspaces, updateWorkspaceVersion } from "coder/site/src/api/api" -import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated" +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 { extractAgents } from "./api-helper" import { CertificateError } from "./error" @@ -11,6 +11,7 @@ import { OpenableTreeItem } from "./workspacesProvider" export class Commands { public constructor( private readonly vscodeProposed: typeof vscode, + private readonly restClient: Api, private readonly storage: Storage, ) {} @@ -82,7 +83,9 @@ export class Commands { if (!url) { return } + this.restClient.setHost(url) + let user: User | undefined let token: string | undefined = args.length >= 2 ? args[1] : undefined if (!token) { const opened = await vscode.env.openExternal(vscode.Uri.parse(`${url}/cli-auth`)) @@ -97,74 +100,72 @@ export class Commands { placeHolder: "Copy your API key from the opened browser page.", value: await this.storage.getSessionToken(), ignoreFocusOut: true, - validateInput: (value) => { - return axios - .get("/api/v2/users/me", { - baseURL: url, - headers: { - "Coder-Session-Token": value, - }, - }) - .then(() => { - return undefined - }) - .catch((err) => { - if (err instanceof CertificateError) { - err.showNotification() - - return { - message: err.x509Err || err.message, - severity: vscode.InputBoxValidationSeverity.Error, - } - } - // This could be something like the header command erroring or an - // invalid session token. - const message = - err?.response?.data?.detail || err?.message || err?.response?.status || "no response from the server" + validateInput: async (value) => { + this.restClient.setSessionToken(value) + try { + user = await this.restClient.getAuthenticatedUser() + if (!user) { + throw new Error("Failed to get authenticated user") + } + } catch (err) { + // For certificate errors show both a notification and add to the + // text under the input box, since users sometimes miss the + // notification. + if (err instanceof CertificateError) { + err.showNotification() + return { - message: "Failed to authenticate: " + message, + message: err.x509Err || err.message, severity: vscode.InputBoxValidationSeverity.Error, } - }) + } + // This could be something like the header command erroring or an + // invalid session token. + const message = getErrorMessage(err, "no response from the server") + return { + message: "Failed to authenticate: " + message, + severity: vscode.InputBoxValidationSeverity.Error, + } + } }, }) } - if (!token) { + if (!token || !user) { return } + // Store these to be used in later sessions and in the cli. await this.storage.setURL(url) await this.storage.setSessionToken(token) - try { - const user = await getAuthenticatedUser() - if (!user) { - throw new Error("Failed to get authenticated user") - } - await vscode.commands.executeCommand("setContext", "coder.authenticated", true) - if (user.roles.find((role) => role.name === "owner")) { - await vscode.commands.executeCommand("setContext", "coder.isOwner", true) - } - vscode.window - .showInformationMessage( - `Welcome to Coder, ${user.username}!`, - { - detail: "You can now use the Coder extension to manage your Coder instance.", - }, - "Open Workspace", - ) - .then((action) => { - if (action === "Open Workspace") { - vscode.commands.executeCommand("coder.open") - } - }) - vscode.commands.executeCommand("coder.refreshWorkspaces") - } catch (error) { - vscode.window.showErrorMessage("Failed to authenticate with Coder: " + error) + + await vscode.commands.executeCommand("setContext", "coder.authenticated", true) + if (user.roles.find((role) => role.name === "owner")) { + await vscode.commands.executeCommand("setContext", "coder.isOwner", true) } + + vscode.window + .showInformationMessage( + `Welcome to Coder, ${user.username}!`, + { + detail: "You can now use the Coder extension to manage your Coder instance.", + }, + "Open Workspace", + ) + .then((action) => { + if (action === "Open Workspace") { + vscode.commands.executeCommand("coder.open") + } + }) + + // Fetch workspaces for the new deployment. + vscode.commands.executeCommand("coder.refreshWorkspaces") } - // viewLogs opens the workspace logs. + /** + * View the logs for the currently logged-in deployment. + */ public async viewLogs(): Promise { + // TODO: This will need to be refactored for multi-deployment support. if (!this.storage.workspaceLogPath) { vscode.window.showInformationMessage("No logs available.", this.storage.workspaceLogPath || "") return @@ -174,48 +175,81 @@ export class Commands { await vscode.window.showTextDocument(doc) } + /** + * Log out from the currently logged-in deployment. + */ public async logout(): Promise { + // Clear from the REST client. An empty url will indicate to other parts of + // the code that we are logged out. + this.restClient.setHost("") + this.restClient.setSessionToken("") + + // Clear from memory. await this.storage.setURL(undefined) await this.storage.setSessionToken(undefined) + await vscode.commands.executeCommand("setContext", "coder.authenticated", false) vscode.window.showInformationMessage("You've been logged out of Coder!", "Login").then((action) => { if (action === "Login") { vscode.commands.executeCommand("coder.login") } }) + + // This will result in clearing the workspace list. vscode.commands.executeCommand("coder.refreshWorkspaces") } + /** + * Create a new workspace for the currently logged-in deployment. + * + * Must only be called if currently logged in. + */ public async createWorkspace(): Promise { - const uri = this.storage.getURL() + "/templates" + const uri = this.storage.getUrl() + "/templates" await vscode.commands.executeCommand("vscode.open", uri) } + /** + * Open a link to the workspace in the Coder dashboard. + * + * Must only be called if currently logged in. + */ public async navigateToWorkspace(workspace: OpenableTreeItem) { if (workspace) { - const uri = this.storage.getURL() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}` + const uri = this.storage.getUrl() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}` await vscode.commands.executeCommand("vscode.open", uri) } else if (this.storage.workspace) { - const uri = this.storage.getURL() + `/@${this.storage.workspace.owner_name}/${this.storage.workspace.name}` + const uri = this.storage.getUrl() + `/@${this.storage.workspace.owner_name}/${this.storage.workspace.name}` await vscode.commands.executeCommand("vscode.open", uri) } else { vscode.window.showInformationMessage("No workspace found.") } } + /** + * Open a link to the workspace settings in the Coder dashboard. + * + * Must only be called if currently logged in. + */ public async navigateToWorkspaceSettings(workspace: OpenableTreeItem) { if (workspace) { - const uri = this.storage.getURL() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}/settings` + const uri = this.storage.getUrl() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}/settings` await vscode.commands.executeCommand("vscode.open", uri) } else if (this.storage.workspace) { const uri = - this.storage.getURL() + `/@${this.storage.workspace.owner_name}/${this.storage.workspace.name}/settings` + this.storage.getUrl() + `/@${this.storage.workspace.owner_name}/${this.storage.workspace.name}/settings` await vscode.commands.executeCommand("vscode.open", uri) } else { vscode.window.showInformationMessage("No workspace found.") } } + /** + * Open a workspace or agent that is showing in the sidebar. + * + * This essentially just builds the host name and passes it to the VS Code + * Remote SSH extension. + */ public async openFromSidebar(treeItem: OpenableTreeItem) { if (treeItem) { await openWorkspace( @@ -228,6 +262,11 @@ export class Commands { } } + /** + * Open a workspace from the currently logged-in deployment. + * + * This must only be called if the REST client is logged in. + */ public async open(...args: unknown[]): Promise { let workspaceOwner: string let workspaceName: string @@ -243,9 +282,10 @@ export class Commands { let lastWorkspaces: readonly Workspace[] quickPick.onDidChangeValue((value) => { quickPick.busy = true - getWorkspaces({ - q: value, - }) + this.restClient + .getWorkspaces({ + q: value, + }) .then((workspaces) => { lastWorkspaces = workspaces.workspaces const items: vscode.QuickPickItem[] = workspaces.workspaces.map((workspace) => { @@ -348,8 +388,12 @@ export class Commands { await openWorkspace(workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent) } + /** + * Update the current workspace. If there is no active workspace connection, + * this is a no-op. + */ public async updateWorkspace(): Promise { - if (!this.storage.workspace) { + if (!this.storage.workspace || !this.storage.restClient) { return } const action = await this.vscodeProposed.window.showInformationMessage( @@ -362,11 +406,15 @@ export class Commands { "Update", ) if (action === "Update") { - await updateWorkspaceVersion(this.storage.workspace) + await this.storage.restClient.updateWorkspaceVersion(this.storage.workspace) } } } +/** + * Given a workspace, build the host name, find a directory to open, and pass + * both to the Remote SSH plugin. + */ async function openWorkspace( workspaceOwner: string, workspaceName: string, diff --git a/src/extension.ts b/src/extension.ts index 0176d71f..9952c260 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,12 +1,9 @@ "use strict" import axios, { isAxiosError } from "axios" -import { getAuthenticatedUser } from "coder/site/src/api/api" import { getErrorMessage } from "coder/site/src/api/errors" -import fs from "fs" -import * as https from "https" import * as module from "module" -import * as os from "os" import * as vscode from "vscode" +import { makeCoderSdk } from "./api" import { Commands } from "./commands" import { CertificateError, getErrorDetail } from "./error" import { Remote } from "./remote" @@ -38,66 +35,16 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { false, ) - // expandPath will expand ${userHome} in the input string. - const expandPath = (input: string): string => { - const userHome = os.homedir() - return input.replace(/\${userHome}/g, userHome) - } - - // applyHttpProperties is called on extension activation and when the - // insecure or TLS setting are changed. It updates the https agent to allow - // self-signed certificates if the insecure setting is true, as well as - // adding cert/key/ca properties for TLS. - const applyHttpProperties = () => { - const cfg = vscode.workspace.getConfiguration() - const insecure = Boolean(cfg.get("coder.insecure")) - const certFile = expandPath(String(cfg.get("coder.tlsCertFile") ?? "").trim()) - const keyFile = expandPath(String(cfg.get("coder.tlsKeyFile") ?? "").trim()) - const caFile = expandPath(String(cfg.get("coder.tlsCaFile") ?? "").trim()) - - axios.defaults.httpsAgent = new https.Agent({ - cert: certFile === "" ? undefined : fs.readFileSync(certFile), - key: keyFile === "" ? undefined : fs.readFileSync(keyFile), - ca: caFile === "" ? undefined : fs.readFileSync(caFile), - // rejectUnauthorized defaults to true, so we need to explicitly set it to false - // if we want to allow self-signed certificates. - rejectUnauthorized: !insecure, - }) - } - - axios.interceptors.response.use( - (r) => r, - async (err) => { - throw await CertificateError.maybeWrap(err, axios.getUri(err.config), storage) - }, - ) - - vscode.workspace.onDidChangeConfiguration((e) => { - if ( - e.affectsConfiguration("coder.insecure") || - e.affectsConfiguration("coder.tlsCertFile") || - e.affectsConfiguration("coder.tlsKeyFile") || - e.affectsConfiguration("coder.tlsCaFile") - ) { - applyHttpProperties() - } - }) - applyHttpProperties() - const output = vscode.window.createOutputChannel("Coder") const storage = new Storage(output, ctx.globalState, ctx.secrets, ctx.globalStorageUri, ctx.logUri) - await storage.init() - - // Add headers from the header command. - axios.interceptors.request.use(async (config) => { - Object.entries(await storage.getHeaders(config.baseURL || axios.getUri(config))).forEach(([key, value]) => { - config.headers[key] = value - }) - return config - }) - const myWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.Mine, storage, 5) - const allWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.All, storage) + // This client tracks the current login and will be used through the life of + // the plugin to poll workspaces for the current login. + const url = storage.getUrl() + const restClient = await makeCoderSdk(url || "", await storage.getSessionToken(), storage) + + const myWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.Mine, restClient, 5) + const allWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.All, restClient) // createTreeView, unlike registerTreeDataProvider, gives us the tree view API // (so we can see when it is visible) but otherwise they have the same effect. @@ -109,15 +56,19 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { myWorkspacesProvider.setVisibility(event.visible) }) - const url = storage.getURL() if (url) { - getAuthenticatedUser() + restClient + .getAuthenticatedUser() .then(async (user) => { if (user && user.roles) { vscode.commands.executeCommand("setContext", "coder.authenticated", true) if (user.roles.find((role) => role.name === "owner")) { await vscode.commands.executeCommand("setContext", "coder.isOwner", true) } + + // Fetch and monitor workspaces, now that we know the client is good. + myWorkspacesProvider.fetchAndRefresh() + allWorkspacesProvider.fetchAndRefresh() } }) .catch((error) => { @@ -155,7 +106,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // queries will default to localhost) so ask for it if missing. // Pre-populate in case we do have the right URL so the user can just // hit enter and move on. - const url = await commands.maybeAskUrl(params.get("url"), storage.getURL()) + const url = await commands.maybeAskUrl(params.get("url"), storage.getUrl()) if (url) { await storage.setURL(url) } else { @@ -174,7 +125,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }, }) - const commands = new Commands(vscodeProposed, storage) + const commands = new Commands(vscodeProposed, restClient, storage) vscode.commands.registerCommand("coder.login", commands.login.bind(commands)) vscode.commands.registerCommand("coder.logout", commands.logout.bind(commands)) diff --git a/src/remote.ts b/src/remote.ts index 5a3d7ad8..f08fdaad 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -1,14 +1,5 @@ import { isAxiosError } from "axios" -import { - getBuildInfo, - getTemplate, - getWorkspace, - getWorkspaceBuildLogs, - getWorkspaceByOwnerAndName, - startWorkspace, - getDeploymentSSHConfig, - getTemplateVersion, -} from "coder/site/src/api/api" +import { Api } from "coder/site/src/api/api" import { ProvisionerJobLog, Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated" import EventSource from "eventsource" import find from "find-process" @@ -20,6 +11,7 @@ import prettyBytes from "pretty-bytes" import * as semver from "semver" import * as vscode from "vscode" import * as ws from "ws" +import { makeCoderSdk } from "./api" import { getHeaderCommand } from "./headers" import { SSHConfig, SSHValues, defaultSSHConfigResponse, mergeSSHConfigValues } from "./sshConfig" import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport" @@ -55,8 +47,45 @@ export class Remote { if (parts.length < 2 || parts.length > 3) { throw new Error(`Invalid Coder SSH authority. Must be: ----`) } + const workspaceName = `${parts[0]}/${parts[1]}` + + // It is possible to connect to any previously connected workspace, which + // might not belong to the deployment the plugin is currently logged into. + // For that reason, create a separate REST client instead of using the + // global one generally used by the plugin. For now this is not actually + // useful because we are using the the current URL and token anyway, but in + // a future PR we will store these per deployment and grab the right one + // based on the host name of the workspace to which we are connecting. + const baseUrlRaw = this.storage.getUrl() + if (!baseUrlRaw) { + const result = await this.vscodeProposed.window.showInformationMessage( + "You are not logged in...", + { + useCustom: true, + modal: true, + detail: `You must log in to access ${workspaceName}.`, + }, + "Log In", + ) + if (!result) { + // User declined to log in. + await this.closeRemote() + } else { + // Log in then try again. + await vscode.commands.executeCommand("coder.login") + await this.setup(remoteAuthority) + } + return + } + + const baseUrl = new URL(baseUrlRaw) + const token = await this.storage.getSessionToken() + const restClient = await makeCoderSdk(baseUrlRaw, token, this.storage) + // Store for use in commands. + this.storage.restClient = restClient - const buildInfo = await getBuildInfo() + // First thing is to check the version. + const buildInfo = await restClient.getBuildInfo() const parsedVersion = semver.parse(buildInfo.version) // Server versions before v0.14.1 don't support the vscodessh command! if ( @@ -79,9 +108,11 @@ export class Remote { } const hasCoderLogs = supportsCoderAgentLogDirFlag(parsedVersion) - // Find the workspace from the URI scheme provided! + // Next is to find the workspace from the URI scheme provided. + let workspace: Workspace try { - this.storage.workspace = await getWorkspaceByOwnerAndName(parts[0], parts[1]) + workspace = await restClient.getWorkspaceByOwnerAndName(parts[0], parts[1]) + this.storage.workspace = workspace } catch (error) { if (!isAxiosError(error)) { throw error @@ -92,7 +123,7 @@ export class Remote { `That workspace doesn't exist!`, { modal: true, - detail: `${parts[0]}/${parts[1]} cannot be found. Maybe it was deleted...`, + detail: `${workspaceName} cannot be found on ${baseUrlRaw}. Maybe it was deleted...`, useCustom: true, }, "Open Workspace", @@ -109,14 +140,14 @@ export class Remote { { useCustom: true, modal: true, - detail: "You must login again to access your workspace.", + detail: `You must log in to access ${workspaceName}.`, }, - "Login", + "Log In", ) if (!result) { await this.closeRemote() } else { - await vscode.commands.executeCommand("coder.login", this.storage.getURL()) + await vscode.commands.executeCommand("coder.login", baseUrlRaw) await this.setup(remoteAuthority) } return @@ -128,31 +159,28 @@ export class Remote { const disposables: vscode.Disposable[] = [] // Register before connection so the label still displays! - disposables.push( - this.registerLabelFormatter(remoteAuthority, this.storage.workspace.owner_name, this.storage.workspace.name), - ) + disposables.push(this.registerLabelFormatter(remoteAuthority, workspace.owner_name, workspace.name)) // Initialize any WorkspaceAction notifications (auto-off, upcoming deletion) - const action = await WorkspaceAction.init(this.vscodeProposed, this.storage) + const action = await WorkspaceAction.init(this.vscodeProposed, restClient, this.storage) + // Make sure the workspace has started. let buildComplete: undefined | (() => void) - if (this.storage.workspace.latest_build.status === "stopped") { + if (workspace.latest_build.status === "stopped") { // If the workspace requires the latest active template version, we should attempt // to update that here. // TODO: If param set changes, what do we do?? - const versionID = this.storage.workspace.template_require_active_version + const versionID = workspace.template_require_active_version ? // Use the latest template version - this.storage.workspace.template_active_version_id + workspace.template_active_version_id : // Default to not updating the workspace if not required. - this.storage.workspace.latest_build.template_version_id + workspace.latest_build.template_version_id this.vscodeProposed.window.withProgress( { location: vscode.ProgressLocation.Notification, cancellable: false, - title: this.storage.workspace.template_require_active_version - ? "Updating workspace..." - : "Starting workspace...", + title: workspace.template_require_active_version ? "Updating workspace..." : "Starting workspace...", }, () => new Promise((r) => { @@ -160,19 +188,20 @@ export class Remote { }), ) - const latestBuild = await startWorkspace(this.storage.workspace.id, versionID) - this.storage.workspace = { - ...this.storage.workspace, + const latestBuild = await restClient.startWorkspace(workspace.id, versionID) + workspace = { + ...workspace, latest_build: latestBuild, } + this.storage.workspace = workspace } // If a build is running we should stream the logs to the user so they can // watch what's going on! if ( - this.storage.workspace.latest_build.status === "pending" || - this.storage.workspace.latest_build.status === "starting" || - this.storage.workspace.latest_build.status === "stopping" + workspace.latest_build.status === "pending" || + workspace.latest_build.status === "starting" || + workspace.latest_build.status === "stopping" ) { const writeEmitter = new vscode.EventEmitter() // We use a terminal instead of an output channel because it feels more @@ -190,28 +219,21 @@ export class Remote { } as Partial as any, }) // This fetches the initial bunch of logs. - const logs = await getWorkspaceBuildLogs(this.storage.workspace.latest_build.id, new Date()) + const logs = await restClient.getWorkspaceBuildLogs(workspace.latest_build.id, new Date()) logs.forEach((log) => writeEmitter.fire(log.output + "\r\n")) terminal.show(true) // This follows the logs for new activity! - let path = `/api/v2/workspacebuilds/${this.storage.workspace.latest_build.id}/logs?follow=true` + // TODO: watchBuildLogsByBuildId exists, but it uses `location`. + // Would be nice if we could use it here. + let path = `/api/v2/workspacebuilds/${workspace.latest_build.id}/logs?follow=true` if (logs.length) { path += `&after=${logs[logs.length - 1].id}` } - const rawURL = this.storage.getURL() - if (!rawURL) { - throw new Error("You aren't logged in!") - } - const url = new URL(rawURL) - const sessionToken = await this.storage.getSessionToken() await new Promise((resolve, reject) => { - let scheme = "wss:" - if (url.protocol === "http:") { - scheme = "ws:" - } - const socket = new ws.WebSocket(new URL(`${scheme}//${url.host}${path}`), { + const proto = baseUrl.protocol === "https:" ? "wss:" : "ws:" + const socket = new ws.WebSocket(new URL(`${proto}//${baseUrl.host}${path}`), { headers: { - "Coder-Session-Token": sessionToken, + "Coder-Session-Token": token, }, }) socket.binaryType = "nodebuffer" @@ -228,14 +250,15 @@ export class Remote { }) }) writeEmitter.fire("Build complete") - this.storage.workspace = await getWorkspace(this.storage.workspace.id) + workspace = await restClient.getWorkspace(workspace.id) + this.storage.workspace = workspace terminal.dispose() if (buildComplete) { buildComplete() } - if (this.storage.workspace.latest_build.status === "stopped") { + if (workspace.latest_build.status === "stopped") { const result = await this.vscodeProposed.window.showInformationMessage( `This workspace is stopped!`, { @@ -253,7 +276,8 @@ export class Remote { } } - const agents = this.storage.workspace.latest_build.resources.reduce((acc, resource) => { + // Pick an agent. + const agents = workspace.latest_build.resources.reduce((acc, resource) => { return acc.concat(resource.agents || []) }, [] as WorkspaceAgent[]) @@ -277,6 +301,7 @@ export class Remote { agent = matchingAgents[0] } + // Do some janky setting manipulation. const hostname = authorityParts[1] const remotePlatforms = this.vscodeProposed.workspace .getConfiguration() @@ -337,11 +362,12 @@ export class Remote { } } + // Watch for workspace updates. const workspaceUpdate = new vscode.EventEmitter() - const watchURL = new URL(`${this.storage.getURL()}/api/v2/workspaces/${this.storage.workspace.id}/watch`) + const watchURL = new URL(`${baseUrlRaw}/api/v2/workspaces/${workspace.id}/watch`) const eventSource = new EventSource(watchURL.toString(), { headers: { - "Coder-Session-Token": await this.storage.getSessionToken(), + "Coder-Session-Token": token, }, }) @@ -353,11 +379,12 @@ export class Remote { // If the newly gotten workspace was updated, then we show a notification // to the user that they should update. if (newWorkspace.outdated) { - if (!this.storage.workspace?.outdated || !hasShownOutdatedNotification) { + if (!workspace.outdated || !hasShownOutdatedNotification) { hasShownOutdatedNotification = true - getTemplate(newWorkspace.template_id) + restClient + .getTemplate(newWorkspace.template_id) .then((template) => { - return getTemplateVersion(template.active_version_id) + return restClient.getTemplateVersion(template.active_version_id) }) .then((version) => { let infoMessage = `A new version of your workspace is available.` @@ -366,7 +393,7 @@ export class Remote { } vscode.window.showInformationMessage(infoMessage, "Update").then((action) => { if (action === "Update") { - vscode.commands.executeCommand("coder.workspace.update", newWorkspace) + vscode.commands.executeCommand("coder.workspace.update", newWorkspace, restClient) } }) }) @@ -385,7 +412,7 @@ export class Remote { workspaceUpdatedStatus.show() } // Show an initial status! - refreshWorkspaceUpdatedStatus(this.storage.workspace) + refreshWorkspaceUpdatedStatus(workspace) eventSource.addEventListener("data", (event: MessageEvent) => { const workspace = JSON.parse(event.data) as Workspace @@ -419,6 +446,7 @@ export class Remote { } }) + // Wait for the agent to connect. if (agent.status === "connecting") { await vscode.window.withProgress( { @@ -456,6 +484,9 @@ export class Remote { ) } + // Make sure agent did not time out. + // TODO: Seems like maybe we should check for all the good states rather + // than one bad state? Agents can error in many ways. if (agent.status === "timeout") { const result = await this.vscodeProposed.window.showErrorMessage("Connection timed out...", { useCustom: true, @@ -476,12 +507,13 @@ export class Remote { // If we didn't write to the SSH config file, connecting would fail with // "Host not found". try { - await this.updateSSHConfig(authorityParts[1], hasCoderLogs) + await this.updateSSHConfig(restClient, authorityParts[1], hasCoderLogs) } catch (error) { this.storage.writeToCoderOutputChannel(`Failed to configure SSH: ${error}`) throw error } + // TODO: This needs to be reworked; it fails to pick up reconnects. this.findSSHProcessID().then((pid) => { if (!pid) { // TODO: Show an error here! @@ -492,7 +524,6 @@ export class Remote { }) // Register the label formatter again because SSH overrides it! - const workspace = this.storage.workspace const agentName = agents.length > 1 ? agent.name : undefined disposables.push( vscode.extensions.onDidChange(() => { @@ -511,10 +542,10 @@ export class Remote { // updateSSHConfig updates the SSH configuration with a wildcard that handles // all Coder entries. - private async updateSSHConfig(hostName: string, hasCoderLogs = false) { + private async updateSSHConfig(restClient: Api, hostName: string, hasCoderLogs = false) { let deploymentSSHConfig = defaultSSHConfigResponse try { - const deploymentConfig = await getDeploymentSSHConfig() + const deploymentConfig = await restClient.getDeploymentSSHConfig() deploymentSSHConfig = deploymentConfig.ssh_config_options } catch (error) { if (!isAxiosError(error)) { @@ -574,7 +605,7 @@ export class Remote { let binaryPath: string | undefined if (this.mode === vscode.ExtensionMode.Production) { - binaryPath = await this.storage.fetchBinary() + binaryPath = await this.storage.fetchBinary(restClient) } else { try { // In development, try to use `/tmp/coder` as the binary path. @@ -582,7 +613,7 @@ export class Remote { binaryPath = path.join(os.tmpdir(), "coder") await fs.stat(binaryPath) } catch (ex) { - binaryPath = await this.storage.fetchBinary() + binaryPath = await this.storage.fetchBinary(restClient) } } diff --git a/src/storage.ts b/src/storage.ts index 99eb7dd3..b0336ccb 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,5 +1,4 @@ -import axios from "axios" -import { getBuildInfo } from "coder/site/src/api/api" +import { Api } from "coder/site/src/api/api" import { Workspace } from "coder/site/src/api/typesGenerated" import { createWriteStream } from "fs" import fs from "fs/promises" @@ -16,8 +15,17 @@ import { getHeaderCommand, getHeaders } from "./headers" const MAX_URLS = 10 export class Storage { + // These will only be populated when actively connected to a workspace and are + // used in commands. Because commands can be executed by the user, it is not + // possible to pass in arguments, so we have to store the current workspace + // and client somewhere, separately from the current login, since you can + // connect to workspaces not belonging to whatever you are logged into (for + // convenience; otherwise the recents menu can be a pain if you use multiple + // deployments). + // TODO: Should maybe store on the Commands class instead. public workspace?: Workspace public workspaceLogPath?: string + public restClient?: Api constructor( private readonly output: vscode.OutputChannel, @@ -27,26 +35,16 @@ export class Storage { private readonly logUri: vscode.Uri, ) {} - /** - * Set the URL and session token on the Axios client and on disk for the cli - * if they are set. - */ - public async init(): Promise { - await this.updateURL(this.getURL()) - await this.updateSessionToken() - } - /** * Add the URL to the list of recently accessed URLs in global storage, then - * set it as the current URL and update it on the Axios client and on disk for - * the cli. + * set it as the last used URL and update it on disk for the cli. * * If the URL is falsey, then remove it as the currently accessed URL and do * not touch the history. */ public async setURL(url?: string): Promise { await this.memento.update("url", url) - this.updateURL(url) + this.updateUrl(url) if (url) { const history = this.withUrlHistory(url) await this.memento.update("urlHistory", history) @@ -54,9 +52,9 @@ export class Storage { } /** - * Get the currently configured URL. + * Get the last used URL. */ - public getURL(): string | undefined { + public getUrl(): string | undefined { return this.memento.get("url") } @@ -78,15 +76,22 @@ export class Storage { return urls.size > MAX_URLS ? Array.from(urls).slice(urls.size - MAX_URLS, urls.size) : Array.from(urls) } + /** + * Set or unset the last used token and update it on disk for the cli. + */ public async setSessionToken(sessionToken?: string): Promise { if (!sessionToken) { await this.secrets.delete("sessionToken") + this.updateSessionToken(undefined) } else { await this.secrets.store("sessionToken", sessionToken) + this.updateSessionToken(sessionToken) } - return this.updateSessionToken() } + /** + * Get the last used token. + */ public async getSessionToken(): Promise { try { return await this.secrets.get("sessionToken") @@ -117,18 +122,16 @@ export class Storage { } /** - * Download and return the path to a working binary. If there is already a - * working binary and it matches the server version, return that, skipping the - * download. If it does not match but downloads are disabled, return whatever - * we have and log a warning. Otherwise throw if unable to download a working - * binary, whether because of network issues or downloads being disabled. + * Download and return the path to a working binary using the provided client. + * If there is already a working binary and it matches the server version, + * return that, skipping the download. If it does not match but downloads are + * disabled, return whatever we have and log a warning. Otherwise throw if + * unable to download a working binary, whether because of network issues or + * downloads being disabled. */ - public async fetchBinary(): Promise { - const baseURL = this.getURL() - if (!baseURL) { - throw new Error("Must be logged in!") - } - this.output.appendLine(`Using deployment URL: ${baseURL}`) + public async fetchBinary(restClient: Api): Promise { + const baseUrl = restClient.getAxiosInstance().defaults.baseURL + this.output.appendLine(`Using deployment URL: ${baseUrl}`) // Settings can be undefined when set to their defaults (true in this case), // so explicitly check against false. @@ -137,13 +140,13 @@ export class Storage { // Get the build info to compare with the existing binary version, if any, // and to log for debugging. - const buildInfo = await getBuildInfo() + const buildInfo = await restClient.getBuildInfo() this.output.appendLine(`Got server version: ${buildInfo.version}`) // Check if there is an existing binary and whether it looks valid. If it // is valid and matches the server, or if it does not match the server but // downloads are disabled, we can return early. - const binPath = this.binaryPath() + const binPath = path.join(this.getBinaryCachePath(), cli.name()) this.output.appendLine(`Using binary path: ${binPath}`) const stat = await cli.stat(binPath) if (stat === undefined) { @@ -198,9 +201,9 @@ export class Storage { // Make the download request. const controller = new AbortController() - const resp = await axios.get(binSource, { + const resp = await restClient.getAxiosInstance().get(binSource, { signal: controller.signal, - baseURL: baseURL, + baseURL: baseUrl, responseType: "stream", headers: { "Accept-Encoding": "gzip", @@ -232,7 +235,7 @@ export class Storage { const completed = await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, - title: `Downloading ${buildInfo.version} from ${axios.getUri(resp.config)} to ${binPath}`, + title: `Downloading ${buildInfo.version} from ${baseUrl} to ${binPath}`, cancellable: true, }, async (progress, token) => { @@ -386,10 +389,16 @@ export class Storage { return path.join(this.globalStorageUri.fsPath, "..", "..", "..", "User", "settings.json") } + /** + * Return the path to the session token file for the cli. + */ public getSessionTokenPath(): string { return path.join(this.globalStorageUri.fsPath, "session_token") } + /** + * Return the path to the URL file for the cli. + */ public getURLPath(): string { return path.join(this.globalStorageUri.fsPath, "url") } @@ -403,11 +412,10 @@ export class Storage { } /** - * Set the URL on the global Axios client and write the URL to disk which will - * be used by the CLI via --url-file. + * Update or remove the URL on disk which can be used by the CLI via + * --url-file. */ - private async updateURL(url: string | undefined): Promise { - axios.defaults.baseURL = url + private async updateUrl(url: string | undefined): Promise { if (url) { await ensureDir(this.globalStorageUri.fsPath) await fs.writeFile(this.getURLPath(), url) @@ -416,23 +424,23 @@ export class Storage { } } - private binaryPath(): string { - return path.join(this.getBinaryCachePath(), cli.name()) - } - - private async updateSessionToken() { - const token = await this.getSessionToken() + /** + * Update or remove the session token on disk which can be used by the CLI + * via --session-token-file. + */ + private async updateSessionToken(token: string | undefined) { if (token) { - axios.defaults.headers.common["Coder-Session-Token"] = token await ensureDir(this.globalStorageUri.fsPath) await fs.writeFile(this.getSessionTokenPath(), token) } else { - delete axios.defaults.headers.common["Coder-Session-Token"] await fs.rm(this.getSessionTokenPath(), { force: true }) } } - public async getHeaders(url = this.getURL()): Promise> { + /** + * Run the header command and return the generated headers. + */ + public async getHeaders(url: string | undefined): Promise> { return getHeaders(url, getHeaderCommand(vscode.workspace.getConfiguration()), this) } } diff --git a/src/workspaceAction.ts b/src/workspaceAction.ts index 85a399d5..eba8cebd 100644 --- a/src/workspaceAction.ts +++ b/src/workspaceAction.ts @@ -1,5 +1,5 @@ import { isAxiosError } from "axios" -import { getWorkspaces } from "coder/site/src/api/api" +import { Api } from "coder/site/src/api/api" import { Workspace, WorkspacesResponse, WorkspaceBuild } from "coder/site/src/api/typesGenerated" import { formatDistanceToNowStrict } from "date-fns" import * as vscode from "vscode" @@ -27,6 +27,7 @@ export class WorkspaceAction { private constructor( private readonly vscodeProposed: typeof vscode, + private readonly restClient: Api, private readonly storage: Storage, ownedWorkspaces: readonly Workspace[], ) { @@ -41,11 +42,11 @@ export class WorkspaceAction { this.pollGetWorkspaces() } - static async init(vscodeProposed: typeof vscode, storage: Storage) { + static async init(vscodeProposed: typeof vscode, restClient: Api, storage: Storage) { // fetch all workspaces owned by the user and set initial public class fields let ownedWorkspacesResponse: WorkspacesResponse try { - ownedWorkspacesResponse = await getWorkspaces({ q: "owner:me" }) + ownedWorkspacesResponse = await restClient.getWorkspaces({ q: "owner:me" }) } catch (error) { let status if (isAxiosError(error)) { @@ -59,7 +60,7 @@ export class WorkspaceAction { ownedWorkspacesResponse = { workspaces: [], count: 0 } } - return new WorkspaceAction(vscodeProposed, storage, ownedWorkspacesResponse.workspaces) + return new WorkspaceAction(vscodeProposed, restClient, storage, ownedWorkspacesResponse.workspaces) } updateNotificationLists() { @@ -108,7 +109,7 @@ export class WorkspaceAction { let errorCount = 0 this.#fetchWorkspacesInterval = setInterval(async () => { try { - const workspacesResult = await getWorkspaces({ q: "owner:me" }) + const workspacesResult = await this.restClient.getWorkspaces({ q: "owner:me" }) this.#ownedWorkspaces = workspacesResult.workspaces this.updateNotificationLists() this.notifyAll() diff --git a/src/workspacesProvider.ts b/src/workspacesProvider.ts index 79c4b652..743e57fb 100644 --- a/src/workspacesProvider.ts +++ b/src/workspacesProvider.ts @@ -1,4 +1,4 @@ -import { getWorkspaces } from "coder/site/src/api/api" +import { Api } from "coder/site/src/api/api" import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated" import EventSource from "eventsource" import * as path from "path" @@ -10,7 +10,6 @@ import { extractAgents, errToStr, } from "./api-helper" -import { Storage } from "./storage" export enum WorkspaceQuery { Mine = "owner:me", @@ -24,6 +23,12 @@ type AgentWatcher = { error?: unknown } +/** + * Polls workspaces using the provided REST client and renders them in a tree. + * + * If the poll fails or the client has no URL configured, clear the tree and + * abort polling until fetchAndRefresh() is called again. + */ export class WorkspaceProvider implements vscode.TreeDataProvider { private workspaces: WorkspaceTreeItem[] = [] private agentWatchers: Record = {} @@ -33,10 +38,10 @@ export class WorkspaceProvider implements vscode.TreeDataProvider { - // Assume that no URL or no token means we are not logged in. - const url = this.storage.getURL() - const token = await this.storage.getSessionToken() - if (!url || !token) { + // If there is no URL configured, assume we are logged out. + const restClient = this.restClient + const url = restClient.getAxiosInstance().defaults.baseURL + if (!url) { throw new Error("not logged in") } - const resp = await getWorkspaces({ q: this.getWorkspacesQuery }) + const resp = await restClient.getWorkspaces({ q: this.getWorkspacesQuery }) // We could have logged out while waiting for the query, or logged into a // different deployment. - const url2 = this.storage.getURL() - const token2 = await this.storage.getSessionToken() - if (!url2 || !token2) { + const url2 = restClient.getAxiosInstance().defaults.baseURL + if (!url2) { throw new Error("not logged in") } else if (url !== url2) { // In this case we need to fetch from the new deployment instead. @@ -117,7 +121,7 @@ export class WorkspaceProvider implements vscode.TreeDataProvider this.refresh()) this.agentWatchers[agent.id] = watcher return watcher @@ -208,7 +212,10 @@ export class WorkspaceProvider implements vscode.TreeDataProvider Date: Wed, 29 May 2024 17:00:52 -0800 Subject: [PATCH 3/5] Use new client in commands for the current workspace These should work now once the clients are pointing to different things. --- src/commands.ts | 54 ++++++++++++++++++++++++++------------- src/extension.ts | 2 +- src/remote.ts | 14 +++++----- src/storage.ts | 13 ---------- src/workspacesProvider.ts | 2 ++ 5 files changed, 47 insertions(+), 38 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index a34ac85d..465bfbe3 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -9,6 +9,17 @@ import { Storage } from "./storage" import { OpenableTreeItem } from "./workspacesProvider" export class Commands { + // These will only be populated when actively connected to a workspace and are + // used in commands. Because commands can be executed by the user, it is not + // possible to pass in arguments, so we have to store the current workspace + // and its client somewhere, separately from the current globally logged-in + // client, since you can connect to workspaces not belonging to whatever you + // are logged into (for convenience; otherwise the recents menu can be a pain + // if you use multiple deployments). + public workspace?: Workspace + public workspaceLogPath?: string + public workspaceRestClient?: Api + public constructor( private readonly vscodeProposed: typeof vscode, private readonly restClient: Api, @@ -162,15 +173,14 @@ export class Commands { } /** - * View the logs for the currently logged-in deployment. + * View the logs for the currently connected workspace. */ public async viewLogs(): Promise { - // TODO: This will need to be refactored for multi-deployment support. - if (!this.storage.workspaceLogPath) { - vscode.window.showInformationMessage("No logs available.", this.storage.workspaceLogPath || "") + if (!this.workspaceLogPath) { + vscode.window.showInformationMessage("No logs available.", this.workspaceLogPath || "") return } - const uri = vscode.Uri.file(this.storage.workspaceLogPath) + const uri = vscode.Uri.file(this.workspaceLogPath) const doc = await vscode.workspace.openTextDocument(uri) await vscode.window.showTextDocument(doc) } @@ -212,14 +222,18 @@ export class Commands { /** * Open a link to the workspace in the Coder dashboard. * - * Must only be called if currently logged in. + * If passing in a workspace, it must belong to the currently logged-in + * deployment. + * + * Otherwise, the currently connected workspace is used (if any). */ public async navigateToWorkspace(workspace: OpenableTreeItem) { if (workspace) { const uri = this.storage.getUrl() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}` await vscode.commands.executeCommand("vscode.open", uri) - } else if (this.storage.workspace) { - const uri = this.storage.getUrl() + `/@${this.storage.workspace.owner_name}/${this.storage.workspace.name}` + } else if (this.workspace && this.workspaceRestClient) { + const baseUrl = this.workspaceRestClient.getAxiosInstance().defaults.baseURL + const uri = `${baseUrl}/@${this.workspace.owner_name}/${this.workspace.name}` await vscode.commands.executeCommand("vscode.open", uri) } else { vscode.window.showInformationMessage("No workspace found.") @@ -229,15 +243,18 @@ export class Commands { /** * Open a link to the workspace settings in the Coder dashboard. * - * Must only be called if currently logged in. + * If passing in a workspace, it must belong to the currently logged-in + * deployment. + * + * Otherwise, the currently connected workspace is used (if any). */ public async navigateToWorkspaceSettings(workspace: OpenableTreeItem) { if (workspace) { const uri = this.storage.getUrl() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}/settings` await vscode.commands.executeCommand("vscode.open", uri) - } else if (this.storage.workspace) { - const uri = - this.storage.getUrl() + `/@${this.storage.workspace.owner_name}/${this.storage.workspace.name}/settings` + } else if (this.workspace && this.workspaceRestClient) { + const baseUrl = this.workspaceRestClient.getAxiosInstance().defaults.baseURL + const uri = `${baseUrl}/@${this.workspace.owner_name}/${this.workspace.name}/settings` await vscode.commands.executeCommand("vscode.open", uri) } else { vscode.window.showInformationMessage("No workspace found.") @@ -248,7 +265,8 @@ export class Commands { * Open a workspace or agent that is showing in the sidebar. * * This essentially just builds the host name and passes it to the VS Code - * Remote SSH extension. + * Remote SSH extension, so it is not necessary to be logged in, although then + * the sidebar would not have any workspaces in it anyway. */ public async openFromSidebar(treeItem: OpenableTreeItem) { if (treeItem) { @@ -263,9 +281,9 @@ export class Commands { } /** - * Open a workspace from the currently logged-in deployment. + * Open a workspace belonging to the currently logged-in deployment. * - * This must only be called if the REST client is logged in. + * This must only be called if logged into a deployment. */ public async open(...args: unknown[]): Promise { let workspaceOwner: string @@ -393,7 +411,7 @@ export class Commands { * this is a no-op. */ public async updateWorkspace(): Promise { - if (!this.storage.workspace || !this.storage.restClient) { + if (!this.workspace || !this.workspaceRestClient) { return } const action = await this.vscodeProposed.window.showInformationMessage( @@ -401,12 +419,12 @@ export class Commands { { useCustom: true, modal: true, - detail: `${this.storage.workspace.owner_name}/${this.storage.workspace.name} will be updated then this window will reload to watch the build logs and reconnect.`, + detail: `${this.workspace.owner_name}/${this.workspace.name} will be updated then this window will reload to watch the build logs and reconnect.`, }, "Update", ) if (action === "Update") { - await this.storage.restClient.updateWorkspaceVersion(this.storage.workspace) + await this.workspaceRestClient.updateWorkspaceVersion(this.workspace) } } } diff --git a/src/extension.ts b/src/extension.ts index 9952c260..599394bc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -150,7 +150,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { if (!vscodeProposed.env.remoteAuthority) { return } - const remote = new Remote(vscodeProposed, storage, ctx.extensionMode) + const remote = new Remote(vscodeProposed, storage, commands, ctx.extensionMode) try { await remote.setup(vscodeProposed.env.remoteAuthority) } catch (ex) { diff --git a/src/remote.ts b/src/remote.ts index f08fdaad..6683b67a 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -12,6 +12,7 @@ import * as semver from "semver" import * as vscode from "vscode" import * as ws from "ws" import { makeCoderSdk } from "./api" +import { Commands } from "./commands" import { getHeaderCommand } from "./headers" import { SSHConfig, SSHValues, defaultSSHConfigResponse, mergeSSHConfigValues } from "./sshConfig" import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport" @@ -27,6 +28,7 @@ export class Remote { public constructor( private readonly vscodeProposed: typeof vscode, private readonly storage: Storage, + private readonly commands: Commands, private readonly mode: vscode.ExtensionMode, ) {} @@ -82,7 +84,7 @@ export class Remote { const token = await this.storage.getSessionToken() const restClient = await makeCoderSdk(baseUrlRaw, token, this.storage) // Store for use in commands. - this.storage.restClient = restClient + this.commands.workspaceRestClient = restClient // First thing is to check the version. const buildInfo = await restClient.getBuildInfo() @@ -112,7 +114,7 @@ export class Remote { let workspace: Workspace try { workspace = await restClient.getWorkspaceByOwnerAndName(parts[0], parts[1]) - this.storage.workspace = workspace + this.commands.workspace = workspace } catch (error) { if (!isAxiosError(error)) { throw error @@ -193,7 +195,7 @@ export class Remote { ...workspace, latest_build: latestBuild, } - this.storage.workspace = workspace + this.commands.workspace = workspace } // If a build is running we should stream the logs to the user so they can @@ -251,7 +253,7 @@ export class Remote { }) writeEmitter.fire("Build complete") workspace = await restClient.getWorkspace(workspace.id) - this.storage.workspace = workspace + this.commands.workspace = workspace terminal.dispose() if (buildComplete) { @@ -420,7 +422,7 @@ export class Remote { return } refreshWorkspaceUpdatedStatus(workspace) - this.storage.workspace = workspace + this.commands.workspace = workspace workspaceUpdate.fire(workspace) if (workspace.latest_build.status === "stopping" || workspace.latest_build.status === "stopped") { const action = this.vscodeProposed.window.showInformationMessage( @@ -520,7 +522,7 @@ export class Remote { return } disposables.push(this.showNetworkUpdates(pid)) - this.storage.workspaceLogPath = path.join(this.storage.getLogPath(), `${pid}.log`) + this.commands.workspaceLogPath = path.join(this.storage.getLogPath(), `${pid}.log`) }) // Register the label formatter again because SSH overrides it! diff --git a/src/storage.ts b/src/storage.ts index b0336ccb..bc72bae3 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,5 +1,4 @@ import { Api } from "coder/site/src/api/api" -import { Workspace } from "coder/site/src/api/typesGenerated" import { createWriteStream } from "fs" import fs from "fs/promises" import { ensureDir } from "fs-extra" @@ -15,18 +14,6 @@ import { getHeaderCommand, getHeaders } from "./headers" const MAX_URLS = 10 export class Storage { - // These will only be populated when actively connected to a workspace and are - // used in commands. Because commands can be executed by the user, it is not - // possible to pass in arguments, so we have to store the current workspace - // and client somewhere, separately from the current login, since you can - // connect to workspaces not belonging to whatever you are logged into (for - // convenience; otherwise the recents menu can be a pain if you use multiple - // deployments). - // TODO: Should maybe store on the Commands class instead. - public workspace?: Workspace - public workspaceLogPath?: string - public restClient?: Api - constructor( private readonly output: vscode.OutputChannel, private readonly memento: vscode.Memento, diff --git a/src/workspacesProvider.ts b/src/workspacesProvider.ts index 743e57fb..4c577c3b 100644 --- a/src/workspacesProvider.ts +++ b/src/workspacesProvider.ts @@ -26,6 +26,8 @@ type AgentWatcher = { /** * Polls workspaces using the provided REST client and renders them in a tree. * + * Polling does not start until fetchAndRefresh() is called at least once. + * * If the poll fails or the client has no URL configured, clear the tree and * abort polling until fetchAndRefresh() is called again. */ From a5efbab76ce8f153a0279e3ad0eb7a8f183ebb9b Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 30 May 2024 16:26:02 -0800 Subject: [PATCH 4/5] Reverse authority parts check --- src/remote.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/remote.ts b/src/remote.ts index 6683b67a..2e7f5194 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -43,10 +43,10 @@ export class Remote { const sshAuthority = authorityParts[1].substring(Remote.Prefix.length) // Authorities are in the format: - // coder-vscode------ Agent can be omitted then - // will be prompted for instead. + // coder-vscode------ + // The agent can be omitted; the user will be prompted for it instead. const parts = sshAuthority.split("--") - if (parts.length < 2 || parts.length > 3) { + if (parts.length !== 2 && parts.length !== 3) { throw new Error(`Invalid Coder SSH authority. Must be: ----`) } const workspaceName = `${parts[0]}/${parts[1]}` From 96c2642c97a00008e0101cef1e659ed35d44f81b Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 30 May 2024 16:31:40 -0800 Subject: [PATCH 5/5] Use temporary client for login --- src/commands.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 465bfbe3..4cac38fb 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -2,6 +2,7 @@ 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 } from "./api" import { extractAgents } from "./api-helper" import { CertificateError } from "./error" import { Remote } from "./remote" @@ -94,7 +95,10 @@ export class Commands { if (!url) { return } - this.restClient.setHost(url) + + // Use a temporary client to avoid messing with the global one while trying + // to log in. + const restClient = await makeCoderSdk(url, undefined, this.storage) let user: User | undefined let token: string | undefined = args.length >= 2 ? args[1] : undefined @@ -112,9 +116,9 @@ export class Commands { value: await this.storage.getSessionToken(), ignoreFocusOut: true, validateInput: async (value) => { - this.restClient.setSessionToken(value) + restClient.setSessionToken(value) try { - user = await this.restClient.getAuthenticatedUser() + user = await restClient.getAuthenticatedUser() if (!user) { throw new Error("Failed to get authenticated user") } @@ -145,6 +149,10 @@ export class Commands { return } + // The URL and token are good; authenticate the global client. + this.restClient.setHost(url) + this.restClient.setSessionToken(token) + // Store these to be used in later sessions and in the cli. await this.storage.setURL(url) await this.storage.setSessionToken(token)