From f4eeb365a9712df9221548d39c54e4fe12fa7df8 Mon Sep 17 00:00:00 2001 From: Aaron Lehmann Date: Fri, 6 Dec 2024 09:41:25 -0800 Subject: [PATCH] Start workspaces by shelling out to CLI (#400) Signed-off-by: Aaron Lehmann --- src/api.ts | 68 ++++++++++++++++++++++++++++++++++++----------- src/remote.ts | 72 +++++++++++++++++++++++++++++++++++--------------- src/storage.ts | 30 +++++++++++++++++++++ 3 files changed, 133 insertions(+), 37 deletions(-) diff --git a/src/api.ts b/src/api.ts index fafeaf56..217a3d67 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,3 +1,4 @@ +import { spawn } from "child_process" import { Api } from "coder/site/src/api/api" import { ProvisionerJobLog, Workspace } from "coder/site/src/api/typesGenerated" import fs from "fs/promises" @@ -122,16 +123,13 @@ export async function makeCoderSdk(baseUrl: string, token: string | undefined, s /** * Start or update a workspace and return the updated workspace. */ -export async function startWorkspaceIfStoppedOrFailed(restClient: Api, workspace: Workspace): Promise { - // 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 = workspace.template_require_active_version - ? // Use the latest template version - workspace.template_active_version_id - : // Default to not updating the workspace if not required. - workspace.latest_build.template_version_id - +export async function startWorkspaceIfStoppedOrFailed( + restClient: Api, + globalConfigDir: string, + binPath: string, + workspace: Workspace, + writeEmitter: vscode.EventEmitter, +): Promise { // Before we start a workspace, we make an initial request to check it's not already started const updatedWorkspace = await restClient.getWorkspace(workspace.id) @@ -139,12 +137,52 @@ export async function startWorkspaceIfStoppedOrFailed(restClient: Api, workspace return updatedWorkspace } - const latestBuild = await restClient.startWorkspace(updatedWorkspace.id, versionID) + return new Promise((resolve, reject) => { + const startArgs = [ + "--global-config", + globalConfigDir, + "start", + "--yes", + workspace.owner_name + "/" + workspace.name, + ] + const startProcess = spawn(binPath, startArgs) + + startProcess.stdout.on("data", (data: Buffer) => { + data + .toString() + .split(/\r*\n/) + .forEach((line: string) => { + if (line !== "") { + writeEmitter.fire(line.toString() + "\r\n") + } + }) + }) + + let capturedStderr = "" + startProcess.stderr.on("data", (data: Buffer) => { + data + .toString() + .split(/\r*\n/) + .forEach((line: string) => { + if (line !== "") { + writeEmitter.fire(line.toString() + "\r\n") + capturedStderr += line.toString() + "\n" + } + }) + }) - return { - ...updatedWorkspace, - latest_build: latestBuild, - } + startProcess.on("close", (code: number) => { + if (code === 0) { + resolve(restClient.getWorkspace(workspace.id)) + } else { + let errorText = `"${startArgs.join(" ")}" exited with code ${code}` + if (capturedStderr !== "") { + errorText += `: ${capturedStderr}` + } + reject(new Error(errorText)) + } + }) + }) } /** diff --git a/src/remote.ts b/src/remote.ts index cd0c3918..abe93e1f 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -50,7 +50,12 @@ export class Remote { /** * Try to get the workspace running. Return undefined if the user canceled. */ - private async maybeWaitForRunning(restClient: Api, workspace: Workspace): Promise { + private async maybeWaitForRunning( + restClient: Api, + workspace: Workspace, + label: string, + binPath: string, + ): Promise { // Maybe already running? if (workspace.latest_build.status === "running") { return workspace @@ -63,6 +68,28 @@ export class Remote { let terminal: undefined | vscode.Terminal let attempts = 0 + function initWriteEmitterAndTerminal(): vscode.EventEmitter { + if (!writeEmitter) { + writeEmitter = new vscode.EventEmitter() + } + if (!terminal) { + terminal = vscode.window.createTerminal({ + name: "Build Log", + location: vscode.TerminalLocation.Panel, + // Spin makes this gear icon spin! + iconPath: new vscode.ThemeIcon("gear~spin"), + pty: { + onDidWrite: writeEmitter.event, + close: () => undefined, + open: () => undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as Partial as any, + }) + terminal.show(true) + } + return writeEmitter + } + try { // Show a notification while we wait. return await this.vscodeProposed.window.withProgress( @@ -72,30 +99,14 @@ export class Remote { title: "Waiting for workspace build...", }, async () => { + const globalConfigDir = path.dirname(this.storage.getSessionTokenPath(label)) while (workspace.latest_build.status !== "running") { ++attempts switch (workspace.latest_build.status) { case "pending": case "starting": case "stopping": - if (!writeEmitter) { - writeEmitter = new vscode.EventEmitter() - } - if (!terminal) { - terminal = vscode.window.createTerminal({ - name: "Build Log", - location: vscode.TerminalLocation.Panel, - // Spin makes this gear icon spin! - iconPath: new vscode.ThemeIcon("gear~spin"), - pty: { - onDidWrite: writeEmitter.event, - close: () => undefined, - open: () => undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as Partial as any, - }) - terminal.show(true) - } + writeEmitter = initWriteEmitterAndTerminal() this.storage.writeToCoderOutputChannel(`Waiting for ${workspaceName}...`) workspace = await waitForBuild(restClient, writeEmitter, workspace) break @@ -103,8 +114,15 @@ export class Remote { if (!(await this.confirmStart(workspaceName))) { return undefined } + writeEmitter = initWriteEmitterAndTerminal() this.storage.writeToCoderOutputChannel(`Starting ${workspaceName}...`) - workspace = await startWorkspaceIfStoppedOrFailed(restClient, workspace) + workspace = await startWorkspaceIfStoppedOrFailed( + restClient, + globalConfigDir, + binPath, + workspace, + writeEmitter, + ) break case "failed": // On a first attempt, we will try starting a failed workspace @@ -113,8 +131,15 @@ export class Remote { if (!(await this.confirmStart(workspaceName))) { return undefined } + writeEmitter = initWriteEmitterAndTerminal() this.storage.writeToCoderOutputChannel(`Starting ${workspaceName}...`) - workspace = await startWorkspaceIfStoppedOrFailed(restClient, workspace) + workspace = await startWorkspaceIfStoppedOrFailed( + restClient, + globalConfigDir, + binPath, + workspace, + writeEmitter, + ) break } // Otherwise fall through and error. @@ -156,6 +181,9 @@ export class Remote { const workspaceName = `${parts.username}/${parts.workspace}` + // Migrate "session_token" file to "session", if needed. + await this.storage.migrateSessionToken(parts.label) + // Get the URL and token belonging to this host. const { url: baseUrlRaw, token } = await this.storage.readCliConfig(parts.label) @@ -292,7 +320,7 @@ export class Remote { disposables.push(this.registerLabelFormatter(remoteAuthority, workspace.owner_name, workspace.name)) // If the workspace is not in a running state, try to get it running. - const updatedWorkspace = await this.maybeWaitForRunning(workspaceRestClient, workspace) + const updatedWorkspace = await this.maybeWaitForRunning(workspaceRestClient, workspace, parts.label, binaryPath) if (!updatedWorkspace) { // User declined to start the workspace. await this.closeRemote() diff --git a/src/storage.ts b/src/storage.ts index a4f2541f..8039a070 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -405,6 +405,20 @@ export class Storage { * The caller must ensure this directory exists before use. */ public getSessionTokenPath(label: string): string { + return label + ? path.join(this.globalStorageUri.fsPath, label, "session") + : path.join(this.globalStorageUri.fsPath, "session") + } + + /** + * Return the directory for the deployment with the provided label to where + * its session token was stored by older code. + * + * If the label is empty, read the old deployment-unaware config instead. + * + * The caller must ensure this directory exists before use. + */ + public getLegacySessionTokenPath(label: string): string { return label ? path.join(this.globalStorageUri.fsPath, label, "session_token") : path.join(this.globalStorageUri.fsPath, "session_token") @@ -488,6 +502,22 @@ export class Storage { } } + /** + * Migrate the session token file from "session_token" to "session", if needed. + */ + public async migrateSessionToken(label: string) { + const oldTokenPath = this.getLegacySessionTokenPath(label) + const newTokenPath = this.getSessionTokenPath(label) + try { + await fs.rename(oldTokenPath, newTokenPath) + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return + } + throw error + } + } + /** * Run the header command and return the generated headers. */