Skip to content

Commit

Permalink
Start workspaces by shelling out to CLI (#400)
Browse files Browse the repository at this point in the history
Signed-off-by: Aaron Lehmann <[email protected]>
  • Loading branch information
aaronlehmann authored Dec 6, 2024
1 parent da1aaed commit f4eeb36
Show file tree
Hide file tree
Showing 3 changed files with 133 additions and 37 deletions.
68 changes: 53 additions & 15 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -122,29 +123,66 @@ 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<Workspace> {
// 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<string>,
): Promise<Workspace> {
// Before we start a workspace, we make an initial request to check it's not already started
const updatedWorkspace = await restClient.getWorkspace(workspace.id)

if (!["stopped", "failed"].includes(updatedWorkspace.latest_build.status)) {
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))
}
})
})
}

/**
Expand Down
72 changes: 50 additions & 22 deletions src/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Workspace | undefined> {
private async maybeWaitForRunning(
restClient: Api,
workspace: Workspace,
label: string,
binPath: string,
): Promise<Workspace | undefined> {
// Maybe already running?
if (workspace.latest_build.status === "running") {
return workspace
Expand All @@ -63,6 +68,28 @@ export class Remote {
let terminal: undefined | vscode.Terminal
let attempts = 0

function initWriteEmitterAndTerminal(): vscode.EventEmitter<string> {
if (!writeEmitter) {
writeEmitter = new vscode.EventEmitter<string>()
}
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<vscode.Pseudoterminal> as any,
})
terminal.show(true)
}
return writeEmitter
}

try {
// Show a notification while we wait.
return await this.vscodeProposed.window.withProgress(
Expand All @@ -72,39 +99,30 @@ 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<string>()
}
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<vscode.Pseudoterminal> as any,
})
terminal.show(true)
}
writeEmitter = initWriteEmitterAndTerminal()
this.storage.writeToCoderOutputChannel(`Waiting for ${workspaceName}...`)
workspace = await waitForBuild(restClient, writeEmitter, workspace)
break
case "stopped":
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
Expand All @@ -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.
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Expand Down
30 changes: 30 additions & 0 deletions src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.
*/
Expand Down

0 comments on commit f4eeb36

Please sign in to comment.