Skip to content

Commit f4b66d6

Browse files
committed
impl: start the workspace via Coder CLI
Netflix uses custom MFA that requires CLI middleware to handle auth flow. The custom CLI implementation on their side intercepts 403 responses from the REST API, handles the MFA challenge, and retries the rest call again. The MFA challenge is handled only by the `start` and `ssh` actions. The remaining actions can go directly to the REST endpoints because of the custom header command that provides MFA tokens to the http calls. Both Gateway and VS Code extension delegate the start logic to the CLI, but not Toolbox which caused issues for the customer. This PR ports some of the work from Gateway in Coder Toolbox.
1 parent 18bffe8 commit f4b66d6

File tree

7 files changed

+113
-22
lines changed

7 files changed

+113
-22
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66

77
- application name can now be displayed as the main title page instead of the URL
88

9+
### Changed
10+
11+
- workspaces are now started with the help of the CLI
12+
913
## 0.7.2 - 2025-11-03
1014

1115
### Changed

src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import com.jetbrains.toolbox.api.ui.actions.ActionDescription
2626
import com.jetbrains.toolbox.api.ui.components.TextType
2727
import com.squareup.moshi.Moshi
2828
import kotlinx.coroutines.CoroutineName
29+
import kotlinx.coroutines.Dispatchers
2930
import kotlinx.coroutines.Job
3031
import kotlinx.coroutines.delay
3132
import kotlinx.coroutines.flow.MutableStateFlow
@@ -36,6 +37,7 @@ import kotlinx.coroutines.launch
3637
import kotlinx.coroutines.withTimeout
3738
import java.io.File
3839
import java.nio.file.Path
40+
import java.util.concurrent.atomic.AtomicBoolean
3941
import kotlin.time.Duration.Companion.minutes
4042
import kotlin.time.Duration.Companion.seconds
4143

@@ -69,6 +71,7 @@ class CoderRemoteEnvironment(
6971
private val networkMetricsMarshaller = Moshi.Builder().build().adapter(NetworkMetrics::class.java)
7072
private val proxyCommandHandle = SshCommandProcessHandle(context)
7173
private var pollJob: Job? = null
74+
private val startIsInProgress = AtomicBoolean(false)
7275

7376
init {
7477
if (context.settingsStore.shouldAutoConnect(id)) {
@@ -120,9 +123,29 @@ class CoderRemoteEnvironment(
120123
)
121124
} else {
122125
actions.add(Action(context, "Start") {
123-
val build = client.startWorkspace(workspace)
124-
update(workspace.copy(latestBuild = build), agent)
125-
126+
try {
127+
// needed in order to make sure Queuing is not overridden by the
128+
// general polling loop with the `Stopped` state
129+
startIsInProgress.set(true)
130+
val startJob = context.cs
131+
.launch(CoroutineName("Start Workspace Action CLI Runner") + Dispatchers.IO) {
132+
cli.startWorkspace(workspace.ownerName, workspace.name)
133+
}
134+
// cli takes 15 seconds to move the workspace in queueing/starting state
135+
// while the user won't see anything happening in TBX after start is clicked
136+
// During those 15 seconds we work around by forcing a `Queuing` state
137+
while (startJob.isActive && client.workspace(workspace.id).latestBuild.status.isNotStarted()) {
138+
state.update {
139+
WorkspaceAndAgentStatus.QUEUED.toRemoteEnvironmentState(context)
140+
}
141+
delay(1.seconds)
142+
}
143+
startIsInProgress.set(false)
144+
// retrieve the status again and update the status
145+
update(client.workspace(workspace.id), agent)
146+
} finally {
147+
startIsInProgress.set(false)
148+
}
126149
}
127150
)
128151
}
@@ -241,6 +264,10 @@ class CoderRemoteEnvironment(
241264
* Update the workspace/agent status to the listeners, if it has changed.
242265
*/
243266
fun update(workspace: Workspace, agent: WorkspaceAgent) {
267+
if (startIsInProgress.get()) {
268+
context.logger.info("Skipping update for $id - workspace start is in progress")
269+
return
270+
}
244271
this.workspace = workspace
245272
this.agent = agent
246273
wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent)

src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ data class Features(
125125
val disableAutostart: Boolean = false,
126126
val reportWorkspaceUsage: Boolean = false,
127127
val wildcardSsh: Boolean = false,
128+
val buildReason: Boolean = false,
128129
)
129130

130131
/**
@@ -304,6 +305,25 @@ class CoderCLIManager(
304305
)
305306
}
306307

308+
/**
309+
* Start a workspace. Throws if the command execution fails.
310+
*/
311+
fun startWorkspace(workspaceOwner: String, workspaceName: String, feats: Features = features): String {
312+
val args = mutableListOf(
313+
"--global-config",
314+
coderConfigPath.toString(),
315+
"start",
316+
"--yes",
317+
"$workspaceOwner/$workspaceName"
318+
)
319+
320+
if (feats.buildReason) {
321+
args.addAll(listOf("--reason", "jetbrains_connection"))
322+
}
323+
324+
return exec(*args.toTypedArray())
325+
}
326+
307327
/**
308328
* Configure SSH to use this binary.
309329
*
@@ -569,7 +589,8 @@ class CoderCLIManager(
569589
Features(
570590
disableAutostart = version >= SemVer(2, 5, 0),
571591
reportWorkspaceUsage = version >= SemVer(2, 13, 0),
572-
version >= SemVer(2, 19, 0),
592+
wildcardSsh = version >= SemVer(2, 19, 0),
593+
buildReason = version >= SemVer(2, 25, 0),
573594
)
574595
}
575596
}

src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ open class CoderRestClient(
241241
/**
242242
* @throws [APIResponseException].
243243
*/
244+
@Deprecated(message = "This operation needs to be delegated to the CLI")
244245
suspend fun startWorkspace(workspace: Workspace): WorkspaceBuild {
245246
val buildRequest = CreateWorkspaceBuildRequest(
246247
null,

src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,41 @@ import java.util.UUID
1010
*/
1111
@JsonClass(generateAdapter = true)
1212
data class WorkspaceBuild(
13-
@Json(name = "template_version_id") val templateVersionID: UUID,
14-
@Json(name = "resources") val resources: List<WorkspaceResource>,
15-
@Json(name = "status") val status: WorkspaceStatus,
13+
@property:Json(name = "template_version_id") val templateVersionID: UUID,
14+
@property:Json(name = "resources") val resources: List<WorkspaceResource>,
15+
@property:Json(name = "status") val status: WorkspaceStatus,
1616
)
1717

1818
enum class WorkspaceStatus {
19-
@Json(name = "pending") PENDING,
20-
@Json(name = "starting") STARTING,
21-
@Json(name = "running") RUNNING,
22-
@Json(name = "stopping") STOPPING,
23-
@Json(name = "stopped") STOPPED,
24-
@Json(name = "failed") FAILED,
25-
@Json(name = "canceling") CANCELING,
26-
@Json(name = "canceled") CANCELED,
27-
@Json(name = "deleting") DELETING,
28-
@Json(name = "deleted") DELETED,
29-
}
19+
@Json(name = "pending")
20+
PENDING,
21+
22+
@Json(name = "starting")
23+
STARTING,
24+
25+
@Json(name = "running")
26+
RUNNING,
27+
28+
@Json(name = "stopping")
29+
STOPPING,
30+
31+
@Json(name = "stopped")
32+
STOPPED,
33+
34+
@Json(name = "failed")
35+
FAILED,
36+
37+
@Json(name = "canceling")
38+
CANCELING,
39+
40+
@Json(name = "canceled")
41+
CANCELED,
42+
43+
@Json(name = "deleting")
44+
DELETING,
45+
46+
@Json(name = "deleted")
47+
DELETED;
48+
49+
fun isNotStarted(): Boolean = this != STARTING && this != RUNNING
50+
}

src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ open class CoderProtocolHandler(
8484
}
8585
reInitialize(restClient, cli)
8686
context.envPageManager.showPluginEnvironmentsPage()
87-
if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return
87+
if (!prepareWorkspace(workspace, restClient, cli, workspaceName, deploymentURL)) return
8888
// we resolve the agent after the workspace is started otherwise we can get misleading
8989
// errors like: no agent available while workspace is starting or stopping
9090
// we also need to retrieve the workspace again to have the latest resources (ex: agent)
@@ -180,6 +180,7 @@ open class CoderProtocolHandler(
180180
private suspend fun prepareWorkspace(
181181
workspace: Workspace,
182182
restClient: CoderRestClient,
183+
cli: CoderCLIManager,
183184
workspaceName: String,
184185
deploymentURL: String
185186
): Boolean {
@@ -207,7 +208,7 @@ open class CoderProtocolHandler(
207208
if (workspace.outdated) {
208209
restClient.updateWorkspace(workspace)
209210
} else {
210-
restClient.startWorkspace(workspace)
211+
cli.startWorkspace(workspace.ownerName, workspace.name)
211212
}
212213
} catch (e: Exception) {
213214
context.logAndShowError(

src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -976,8 +976,24 @@ internal class CoderCLIManagerTest {
976976
val tests =
977977
listOf(
978978
Pair("2.5.0", Features(true)),
979-
Pair("2.13.0", Features(true, true)),
980-
Pair("4.9.0", Features(true, true, true)),
979+
Pair("2.13.0", Features(disableAutostart = true, reportWorkspaceUsage = true)),
980+
Pair(
981+
"2.25.0",
982+
Features(
983+
disableAutostart = true,
984+
reportWorkspaceUsage = true,
985+
wildcardSsh = true,
986+
buildReason = true
987+
)
988+
),
989+
Pair(
990+
"4.9.0", Features(
991+
disableAutostart = true,
992+
reportWorkspaceUsage = true,
993+
wildcardSsh = true,
994+
buildReason = true
995+
)
996+
),
981997
Pair("2.4.9", Features(false)),
982998
Pair("1.0.1", Features(false)),
983999
)

0 commit comments

Comments
 (0)