Skip to content

Commit f4eeb36

Browse files
authored
Start workspaces by shelling out to CLI (#400)
Signed-off-by: Aaron Lehmann <[email protected]>
1 parent da1aaed commit f4eeb36

File tree

3 files changed

+133
-37
lines changed

3 files changed

+133
-37
lines changed

src/api.ts

+53-15
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { spawn } from "child_process"
12
import { Api } from "coder/site/src/api/api"
23
import { ProvisionerJobLog, Workspace } from "coder/site/src/api/typesGenerated"
34
import fs from "fs/promises"
@@ -122,29 +123,66 @@ export async function makeCoderSdk(baseUrl: string, token: string | undefined, s
122123
/**
123124
* Start or update a workspace and return the updated workspace.
124125
*/
125-
export async function startWorkspaceIfStoppedOrFailed(restClient: Api, workspace: Workspace): Promise<Workspace> {
126-
// If the workspace requires the latest active template version, we should attempt
127-
// to update that here.
128-
// TODO: If param set changes, what do we do??
129-
const versionID = workspace.template_require_active_version
130-
? // Use the latest template version
131-
workspace.template_active_version_id
132-
: // Default to not updating the workspace if not required.
133-
workspace.latest_build.template_version_id
134-
126+
export async function startWorkspaceIfStoppedOrFailed(
127+
restClient: Api,
128+
globalConfigDir: string,
129+
binPath: string,
130+
workspace: Workspace,
131+
writeEmitter: vscode.EventEmitter<string>,
132+
): Promise<Workspace> {
135133
// Before we start a workspace, we make an initial request to check it's not already started
136134
const updatedWorkspace = await restClient.getWorkspace(workspace.id)
137135

138136
if (!["stopped", "failed"].includes(updatedWorkspace.latest_build.status)) {
139137
return updatedWorkspace
140138
}
141139

142-
const latestBuild = await restClient.startWorkspace(updatedWorkspace.id, versionID)
140+
return new Promise((resolve, reject) => {
141+
const startArgs = [
142+
"--global-config",
143+
globalConfigDir,
144+
"start",
145+
"--yes",
146+
workspace.owner_name + "/" + workspace.name,
147+
]
148+
const startProcess = spawn(binPath, startArgs)
149+
150+
startProcess.stdout.on("data", (data: Buffer) => {
151+
data
152+
.toString()
153+
.split(/\r*\n/)
154+
.forEach((line: string) => {
155+
if (line !== "") {
156+
writeEmitter.fire(line.toString() + "\r\n")
157+
}
158+
})
159+
})
160+
161+
let capturedStderr = ""
162+
startProcess.stderr.on("data", (data: Buffer) => {
163+
data
164+
.toString()
165+
.split(/\r*\n/)
166+
.forEach((line: string) => {
167+
if (line !== "") {
168+
writeEmitter.fire(line.toString() + "\r\n")
169+
capturedStderr += line.toString() + "\n"
170+
}
171+
})
172+
})
143173

144-
return {
145-
...updatedWorkspace,
146-
latest_build: latestBuild,
147-
}
174+
startProcess.on("close", (code: number) => {
175+
if (code === 0) {
176+
resolve(restClient.getWorkspace(workspace.id))
177+
} else {
178+
let errorText = `"${startArgs.join(" ")}" exited with code ${code}`
179+
if (capturedStderr !== "") {
180+
errorText += `: ${capturedStderr}`
181+
}
182+
reject(new Error(errorText))
183+
}
184+
})
185+
})
148186
}
149187

150188
/**

src/remote.ts

+50-22
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,12 @@ export class Remote {
5050
/**
5151
* Try to get the workspace running. Return undefined if the user canceled.
5252
*/
53-
private async maybeWaitForRunning(restClient: Api, workspace: Workspace): Promise<Workspace | undefined> {
53+
private async maybeWaitForRunning(
54+
restClient: Api,
55+
workspace: Workspace,
56+
label: string,
57+
binPath: string,
58+
): Promise<Workspace | undefined> {
5459
// Maybe already running?
5560
if (workspace.latest_build.status === "running") {
5661
return workspace
@@ -63,6 +68,28 @@ export class Remote {
6368
let terminal: undefined | vscode.Terminal
6469
let attempts = 0
6570

71+
function initWriteEmitterAndTerminal(): vscode.EventEmitter<string> {
72+
if (!writeEmitter) {
73+
writeEmitter = new vscode.EventEmitter<string>()
74+
}
75+
if (!terminal) {
76+
terminal = vscode.window.createTerminal({
77+
name: "Build Log",
78+
location: vscode.TerminalLocation.Panel,
79+
// Spin makes this gear icon spin!
80+
iconPath: new vscode.ThemeIcon("gear~spin"),
81+
pty: {
82+
onDidWrite: writeEmitter.event,
83+
close: () => undefined,
84+
open: () => undefined,
85+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
86+
} as Partial<vscode.Pseudoterminal> as any,
87+
})
88+
terminal.show(true)
89+
}
90+
return writeEmitter
91+
}
92+
6693
try {
6794
// Show a notification while we wait.
6895
return await this.vscodeProposed.window.withProgress(
@@ -72,39 +99,30 @@ export class Remote {
7299
title: "Waiting for workspace build...",
73100
},
74101
async () => {
102+
const globalConfigDir = path.dirname(this.storage.getSessionTokenPath(label))
75103
while (workspace.latest_build.status !== "running") {
76104
++attempts
77105
switch (workspace.latest_build.status) {
78106
case "pending":
79107
case "starting":
80108
case "stopping":
81-
if (!writeEmitter) {
82-
writeEmitter = new vscode.EventEmitter<string>()
83-
}
84-
if (!terminal) {
85-
terminal = vscode.window.createTerminal({
86-
name: "Build Log",
87-
location: vscode.TerminalLocation.Panel,
88-
// Spin makes this gear icon spin!
89-
iconPath: new vscode.ThemeIcon("gear~spin"),
90-
pty: {
91-
onDidWrite: writeEmitter.event,
92-
close: () => undefined,
93-
open: () => undefined,
94-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
95-
} as Partial<vscode.Pseudoterminal> as any,
96-
})
97-
terminal.show(true)
98-
}
109+
writeEmitter = initWriteEmitterAndTerminal()
99110
this.storage.writeToCoderOutputChannel(`Waiting for ${workspaceName}...`)
100111
workspace = await waitForBuild(restClient, writeEmitter, workspace)
101112
break
102113
case "stopped":
103114
if (!(await this.confirmStart(workspaceName))) {
104115
return undefined
105116
}
117+
writeEmitter = initWriteEmitterAndTerminal()
106118
this.storage.writeToCoderOutputChannel(`Starting ${workspaceName}...`)
107-
workspace = await startWorkspaceIfStoppedOrFailed(restClient, workspace)
119+
workspace = await startWorkspaceIfStoppedOrFailed(
120+
restClient,
121+
globalConfigDir,
122+
binPath,
123+
workspace,
124+
writeEmitter,
125+
)
108126
break
109127
case "failed":
110128
// On a first attempt, we will try starting a failed workspace
@@ -113,8 +131,15 @@ export class Remote {
113131
if (!(await this.confirmStart(workspaceName))) {
114132
return undefined
115133
}
134+
writeEmitter = initWriteEmitterAndTerminal()
116135
this.storage.writeToCoderOutputChannel(`Starting ${workspaceName}...`)
117-
workspace = await startWorkspaceIfStoppedOrFailed(restClient, workspace)
136+
workspace = await startWorkspaceIfStoppedOrFailed(
137+
restClient,
138+
globalConfigDir,
139+
binPath,
140+
workspace,
141+
writeEmitter,
142+
)
118143
break
119144
}
120145
// Otherwise fall through and error.
@@ -156,6 +181,9 @@ export class Remote {
156181

157182
const workspaceName = `${parts.username}/${parts.workspace}`
158183

184+
// Migrate "session_token" file to "session", if needed.
185+
await this.storage.migrateSessionToken(parts.label)
186+
159187
// Get the URL and token belonging to this host.
160188
const { url: baseUrlRaw, token } = await this.storage.readCliConfig(parts.label)
161189

@@ -292,7 +320,7 @@ export class Remote {
292320
disposables.push(this.registerLabelFormatter(remoteAuthority, workspace.owner_name, workspace.name))
293321

294322
// If the workspace is not in a running state, try to get it running.
295-
const updatedWorkspace = await this.maybeWaitForRunning(workspaceRestClient, workspace)
323+
const updatedWorkspace = await this.maybeWaitForRunning(workspaceRestClient, workspace, parts.label, binaryPath)
296324
if (!updatedWorkspace) {
297325
// User declined to start the workspace.
298326
await this.closeRemote()

src/storage.ts

+30
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,20 @@ export class Storage {
405405
* The caller must ensure this directory exists before use.
406406
*/
407407
public getSessionTokenPath(label: string): string {
408+
return label
409+
? path.join(this.globalStorageUri.fsPath, label, "session")
410+
: path.join(this.globalStorageUri.fsPath, "session")
411+
}
412+
413+
/**
414+
* Return the directory for the deployment with the provided label to where
415+
* its session token was stored by older code.
416+
*
417+
* If the label is empty, read the old deployment-unaware config instead.
418+
*
419+
* The caller must ensure this directory exists before use.
420+
*/
421+
public getLegacySessionTokenPath(label: string): string {
408422
return label
409423
? path.join(this.globalStorageUri.fsPath, label, "session_token")
410424
: path.join(this.globalStorageUri.fsPath, "session_token")
@@ -488,6 +502,22 @@ export class Storage {
488502
}
489503
}
490504

505+
/**
506+
* Migrate the session token file from "session_token" to "session", if needed.
507+
*/
508+
public async migrateSessionToken(label: string) {
509+
const oldTokenPath = this.getLegacySessionTokenPath(label)
510+
const newTokenPath = this.getSessionTokenPath(label)
511+
try {
512+
await fs.rename(oldTokenPath, newTokenPath)
513+
} catch (error) {
514+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
515+
return
516+
}
517+
throw error
518+
}
519+
}
520+
491521
/**
492522
* Run the header command and return the generated headers.
493523
*/

0 commit comments

Comments
 (0)