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..4cac38fb 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,7 +1,8 @@ -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 { makeCoderSdk } from "./api" import { extractAgents } from "./api-helper" import { CertificateError } from "./error" import { Remote } from "./remote" @@ -9,8 +10,20 @@ 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, private readonly storage: Storage, ) {} @@ -83,6 +96,11 @@ export class Commands { return } + // 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 if (!token) { const opened = await vscode.env.openExternal(vscode.Uri.parse(`${url}/cli-auth`)) @@ -97,125 +115,167 @@ 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) => { + restClient.setSessionToken(value) + try { + user = await 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 } + // 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) - 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 connected workspace. + */ public async viewLogs(): Promise { - 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) } + /** + * 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. + * + * 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}` + 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.") } } + /** + * Open a link to the workspace settings in the Coder dashboard. + * + * 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` + 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.") } } + /** + * 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, 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) { await openWorkspace( @@ -228,6 +288,11 @@ export class Commands { } } + /** + * Open a workspace belonging to the currently logged-in deployment. + * + * This must only be called if logged into a deployment. + */ public async open(...args: unknown[]): Promise { let workspaceOwner: string let workspaceName: string @@ -243,9 +308,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 +414,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.workspace || !this.workspaceRestClient) { return } const action = await this.vscodeProposed.window.showInformationMessage( @@ -357,16 +427,20 @@ 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 updateWorkspaceVersion(this.storage.workspace) + 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. + */ async function openWorkspace( workspaceOwner: string, workspaceName: string, diff --git a/src/extension.ts b/src/extension.ts index 0176d71f..599394bc 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)) @@ -199,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 5a3d7ad8..2e7f5194 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,8 @@ 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 { Commands } from "./commands" import { getHeaderCommand } from "./headers" import { SSHConfig, SSHValues, defaultSSHConfigResponse, mergeSSHConfigValues } from "./sshConfig" import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport" @@ -35,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, ) {} @@ -49,14 +43,51 @@ 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]}` + + // 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.commands.workspaceRestClient = 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 +110,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.commands.workspace = workspace } catch (error) { if (!isAxiosError(error)) { throw error @@ -92,7 +125,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 +142,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 +161,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 +190,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.commands.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 +221,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 +252,15 @@ export class Remote { }) }) writeEmitter.fire("Build complete") - this.storage.workspace = await getWorkspace(this.storage.workspace.id) + workspace = await restClient.getWorkspace(workspace.id) + this.commands.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 +278,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 +303,7 @@ export class Remote { agent = matchingAgents[0] } + // Do some janky setting manipulation. const hostname = authorityParts[1] const remotePlatforms = this.vscodeProposed.workspace .getConfiguration() @@ -337,11 +364,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 +381,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 +395,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 +414,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 @@ -393,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( @@ -419,6 +448,7 @@ export class Remote { } }) + // Wait for the agent to connect. if (agent.status === "connecting") { await vscode.window.withProgress( { @@ -456,6 +486,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,23 +509,23 @@ 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! 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! - const workspace = this.storage.workspace const agentName = agents.length > 1 ? agent.name : undefined disposables.push( vscode.extensions.onDidChange(() => { @@ -511,10 +544,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 +607,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 +615,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 6c793c05..bc72bae3 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,6 +1,4 @@ -import axios from "axios" -import { getBuildInfo } from "coder/site/src/api/api" -import { Workspace } from "coder/site/src/api/typesGenerated" +import { Api } from "coder/site/src/api/api" import { createWriteStream } from "fs" import fs from "fs/promises" import { ensureDir } from "fs-extra" @@ -16,9 +14,6 @@ import { getHeaderCommand, getHeaders } from "./headers" const MAX_URLS = 10 export class Storage { - public workspace?: Workspace - public workspaceLogPath?: string - constructor( private readonly output: vscode.OutputChannel, private readonly memento: vscode.Memento, @@ -27,26 +22,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 +39,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,17 +63,22 @@ 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 { + /** + * Set or unset the last used token and update it on disk for the cli. + */ + public async setSessionToken(sessionToken?: string): Promise { if (!sessionToken) { - return this.secrets.delete("sessionToken").then(() => { - return this.updateSessionToken() - }) + await this.secrets.delete("sessionToken") + this.updateSessionToken(undefined) + } else { + await this.secrets.store("sessionToken", sessionToken) + this.updateSessionToken(sessionToken) } - return this.secrets.store("sessionToken", sessionToken).then(() => { - return this.updateSessionToken() - }) } + /** + * Get the last used token. + */ public async getSessionToken(): Promise { try { return await this.secrets.get("sessionToken") @@ -119,18 +109,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. @@ -139,13 +127,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) { @@ -200,9 +188,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", @@ -234,7 +222,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) => { @@ -388,10 +376,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") } @@ -405,11 +399,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) @@ -418,23 +411,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..4c577c3b 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,14 @@ type AgentWatcher = { error?: unknown } +/** + * 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. + */ export class WorkspaceProvider implements vscode.TreeDataProvider { private workspaces: WorkspaceTreeItem[] = [] private agentWatchers: Record = {} @@ -33,10 +40,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 +123,7 @@ export class WorkspaceProvider implements vscode.TreeDataProvider this.refresh()) this.agentWatchers[agent.id] = watcher return watcher @@ -208,7 +214,10 @@ export class WorkspaceProvider implements vscode.TreeDataProvider