Skip to content

Commit f4eeb36

Browse files
authored
Start workspaces by shelling out to CLI (#400)
Signed-off-by: Aaron Lehmann <alehmann@netflix.com>
1 parent da1aaed commit f4eeb36

File tree

3 files changed

+133
-37
lines changed

3 files changed

+133
-37
lines changed

src/api.ts

Lines changed: 53 additions & 15 deletions
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

Lines changed: 50 additions & 22 deletions
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

Lines changed: 30 additions & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy