Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Start workspaces by shelling out to CLI #400

Merged
merged 6 commits into from
Dec 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
code-asher marked this conversation as resolved.
Show resolved Hide resolved
]
const startProcess = spawn(binPath, startArgs)

startProcess.stdout.on("data", (data: Buffer) => {
data
.toString()
.split(/\r*\n/)
.forEach((line: string) => {
if (line !== "") {
code-asher marked this conversation as resolved.
Show resolved Hide resolved
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