Skip to content

Commit 9eaeb0f

Browse files
committed
Support multiple deployments
This also lets us set a custom environment variable to track JetBrains sessions.
1 parent 56909a3 commit 9eaeb0f

File tree

5 files changed

+556
-32
lines changed

5 files changed

+556
-32
lines changed

src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@ data class CoderWorkspacesWizardModel(
44
var coderURL: String = "https://coder.example.com",
55
var token: String = "",
66
var selectedWorkspace: WorkspaceAgentModel? = null,
7-
var useExistingToken: Boolean = false
7+
var useExistingToken: Boolean = false,
88
)

src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt

Lines changed: 102 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.coder.gateway.sdk
22

3+
import com.coder.gateway.models.WorkspaceAgentModel
34
import com.coder.gateway.views.steps.CoderWorkspacesStepView
45
import com.intellij.openapi.diagnostic.Logger
56
import org.zeroturnaround.exec.ProcessExecutor
@@ -23,23 +24,25 @@ import javax.xml.bind.annotation.adapters.HexBinaryAdapter
2324
/**
2425
* Manage the CLI for a single deployment.
2526
*/
26-
class CoderCLIManager @JvmOverloads constructor(private val deployment: URL, destinationDir: Path = getDataDir()) {
27+
class CoderCLIManager @JvmOverloads constructor(private val deploymentURL: URL, destinationDir: Path = getDataDir()) {
2728
private var remoteBinaryUrl: URL
2829
var localBinaryPath: Path
30+
private var coderConfigPath: Path
2931

3032
init {
3133
val binaryName = getCoderCLIForOS(getOS(), getArch())
3234
remoteBinaryUrl = URL(
33-
deployment.protocol,
34-
deployment.host,
35-
deployment.port,
35+
deploymentURL.protocol,
36+
deploymentURL.host,
37+
deploymentURL.port,
3638
"/bin/$binaryName"
3739
)
3840
// Convert IDN to ASCII in case the file system cannot support the
3941
// necessary character set.
40-
val host = IDN.toASCII(deployment.host, IDN.ALLOW_UNASSIGNED)
41-
val subdir = if (deployment.port > 0) "${host}-${deployment.port}" else host
42+
val host = getSafeHost(deploymentURL)
43+
val subdir = if (deploymentURL.port > 0) "${host}-${deploymentURL.port}" else host
4244
localBinaryPath = destinationDir.resolve(subdir).resolve(binaryName)
45+
coderConfigPath = destinationDir.resolve(subdir).resolve("config")
4346
}
4447

4548
/**
@@ -146,17 +149,93 @@ class CoderCLIManager @JvmOverloads constructor(private val deployment: URL, des
146149
* Use the provided token to authenticate the CLI.
147150
*/
148151
fun login(token: String): String {
149-
return exec("login", deployment.toString(), "--token", token)
152+
logger.info("Storing CLI credentials in $coderConfigPath")
153+
return exec(
154+
"login",
155+
deploymentURL.toString(),
156+
"--token",
157+
token,
158+
"--global-config",
159+
coderConfigPath.toAbsolutePath().toString(),
160+
)
150161
}
151162

152163
/**
153164
* Configure SSH to use this binary.
154-
*
155-
* TODO: Support multiple deployments; currently they will clobber each
156-
* other.
157165
*/
158-
fun configSsh(): String {
159-
return exec("config-ssh", "--yes", "--use-previous-options")
166+
fun configSsh(
167+
workspaces: List<WorkspaceAgentModel>,
168+
sshConfigPath: Path = Path.of(System.getProperty("user.home")).resolve(".ssh/config"),
169+
) {
170+
val host = getSafeHost(deploymentURL)
171+
val startBlock = "# --- START CODER JETBRAINS $host"
172+
val endBlock = "# --- END CODER JETBRAINS $host"
173+
val isRemoving = workspaces.isEmpty()
174+
val blockContent = workspaces.joinToString(
175+
System.lineSeparator(),
176+
startBlock + System.lineSeparator(),
177+
System.lineSeparator() + endBlock,
178+
transform = {
179+
"""
180+
Host ${getHostName(deploymentURL, it)}
181+
HostName coder.${it.name}
182+
ProxyCommand "${localBinaryPath.toAbsolutePath()}" --global-config "${coderConfigPath.toAbsolutePath()}" ssh --stdio ${it.name}
183+
ConnectTimeout 0
184+
StrictHostKeyChecking no
185+
UserKnownHostsFile /dev/null
186+
LogLevel ERROR
187+
SetEnv CODER_SSH_SESSION_TYPE=JetBrains
188+
""".trimIndent().replace("\n", System.lineSeparator())
189+
})
190+
Files.createDirectories(sshConfigPath.parent)
191+
try {
192+
val contents = sshConfigPath.toFile().readText()
193+
val start = "(\\s*)$startBlock".toRegex().find(contents)
194+
val end = "$endBlock(\\s*)".toRegex().find(contents)
195+
if (start == null && end == null && isRemoving) {
196+
logger.info("Leaving $sshConfigPath alone since there are no workspaces and no config to remove")
197+
} else if (start == null && end == null) {
198+
logger.info("Appending config to $sshConfigPath")
199+
sshConfigPath.toFile().writeText(
200+
if (contents.isEmpty()) blockContent else listOf(
201+
contents,
202+
blockContent
203+
).joinToString(System.lineSeparator())
204+
)
205+
} else if (start == null) {
206+
throw SSHConfigFormatException("End block exists but no start block")
207+
} else if (end == null) {
208+
throw SSHConfigFormatException("Start block exists but no end block")
209+
} else if (start.range.first > end.range.first) {
210+
throw SSHConfigFormatException("Start block found after end block")
211+
} else if (isRemoving) {
212+
logger.info("Removing config from $sshConfigPath")
213+
sshConfigPath.toFile().writeText(
214+
listOf(
215+
contents.substring(0, start.range.first),
216+
// Need to keep the trailing newline(s) if we are not at
217+
// the front of the file otherwise the before and after
218+
// lines would get joined.
219+
if (start.range.first > 0) end.groupValues[1] else "",
220+
contents.substring(end.range.last + 1)
221+
).joinToString("")
222+
)
223+
} else {
224+
logger.info("Replacing config in $sshConfigPath")
225+
sshConfigPath.toFile().writeText(
226+
listOf(
227+
contents.substring(0, start.range.first),
228+
start.groupValues[1], // Leading newline(s).
229+
blockContent,
230+
end.groupValues[1], // Trailing newline(s).
231+
contents.substring(end.range.last + 1)
232+
).joinToString("")
233+
)
234+
}
235+
} catch (e: FileNotFoundException) {
236+
logger.info("Writing config to $sshConfigPath")
237+
sshConfigPath.toFile().writeText(blockContent)
238+
}
160239
}
161240

162241
/**
@@ -241,6 +320,15 @@ class CoderCLIManager @JvmOverloads constructor(private val deployment: URL, des
241320
}
242321
}
243322
}
323+
324+
private fun getSafeHost(url: URL): String {
325+
return IDN.toASCII(url.host, IDN.ALLOW_UNASSIGNED)
326+
}
327+
328+
@JvmStatic
329+
fun getHostName(url: URL, ws: WorkspaceAgentModel): String {
330+
return "coder-jetbrains--${ws.name}--${getSafeHost(url)}"
331+
}
244332
}
245333
}
246334

@@ -255,3 +343,5 @@ class Environment(private val env: Map<String, String> = emptyMap()) {
255343
}
256344

257345
class ResponseException(message: String, val code: Int) : Exception(message)
346+
347+
class SSHConfigFormatException(message: String) : Exception(message)

src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import com.coder.gateway.icons.CoderIcons
55
import com.coder.gateway.models.CoderWorkspacesWizardModel
66
import com.coder.gateway.models.WorkspaceAgentModel
77
import com.coder.gateway.sdk.Arch
8+
import com.coder.gateway.sdk.CoderCLIManager
89
import com.coder.gateway.sdk.CoderRestClientService
910
import com.coder.gateway.sdk.OS
11+
import com.coder.gateway.sdk.toURL
1012
import com.coder.gateway.sdk.withPath
1113
import com.coder.gateway.toWorkspaceParams
1214
import com.coder.gateway.views.LazyBrowserLink
@@ -30,7 +32,12 @@ import com.intellij.ui.AnimatedIcon
3032
import com.intellij.ui.ColoredListCellRenderer
3133
import com.intellij.ui.DocumentAdapter
3234
import com.intellij.ui.components.JBTextField
33-
import com.intellij.ui.dsl.builder.*
35+
import com.intellij.ui.dsl.builder.AlignX
36+
import com.intellij.ui.dsl.builder.BottomGap
37+
import com.intellij.ui.dsl.builder.RightGap
38+
import com.intellij.ui.dsl.builder.RowLayout
39+
import com.intellij.ui.dsl.builder.TopGap
40+
import com.intellij.ui.dsl.builder.panel
3441
import com.intellij.util.ui.JBFont
3542
import com.intellij.util.ui.JBUI
3643
import com.intellij.util.ui.UIUtil
@@ -61,7 +68,7 @@ import kotlinx.coroutines.withContext
6168
import java.awt.Component
6269
import java.awt.FlowLayout
6370
import java.time.Duration
64-
import java.util.*
71+
import java.util.Locale
6572
import javax.swing.ComboBoxModel
6673
import javax.swing.DefaultComboBoxModel
6774
import javax.swing.JLabel
@@ -151,9 +158,11 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
151158
override fun onInit(wizardModel: CoderWorkspacesWizardModel) {
152159
cbIDE.renderer = IDECellRenderer()
153160
ideComboBoxModel.removeAllElements()
161+
val deploymentURL = wizardModel.coderURL.toURL()
154162
val selectedWorkspace = wizardModel.selectedWorkspace
155163
if (selectedWorkspace == null) {
156-
logger.warn("No workspace was selected. Please go back to the previous step and select a Coder Workspace")
164+
// TODO: Should be impossible, tweak the types/flow to enforce this.
165+
logger.warn("No workspace was selected. Please go back to the previous step and select a workspace")
157166
return
158167
}
159168

@@ -163,7 +172,9 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
163172

164173
ideResolvingJob = cs.launch {
165174
try {
166-
val executor = withTimeout(Duration.ofSeconds(60)) { createRemoteExecutor(selectedWorkspace) }
175+
val executor = withTimeout(Duration.ofSeconds(60)) {
176+
createRemoteExecutor(CoderCLIManager.getHostName(deploymentURL, selectedWorkspace))
177+
}
167178
retrieveIDES(executor, selectedWorkspace)
168179
if (ComponentValidator.getInstance(tfProject).isEmpty) {
169180
installRemotePathValidator(executor)
@@ -235,10 +246,10 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
235246
})
236247
}
237248

238-
private suspend fun createRemoteExecutor(selectedWorkspace: WorkspaceAgentModel): HighLevelHostAccessor {
249+
private suspend fun createRemoteExecutor(host: String): HighLevelHostAccessor {
239250
return HighLevelHostAccessor.create(
240251
RemoteCredentialsHolder().apply {
241-
setHost("coder.${selectedWorkspace.name}")
252+
setHost(host)
242253
userName = "coder"
243254
port = 22
244255
authType = AuthType.OPEN_SSH
@@ -310,11 +321,18 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
310321
override fun onNext(wizardModel: CoderWorkspacesWizardModel): Boolean {
311322
val selectedIDE = cbIDE.selectedItem ?: return false
312323
logger.info("Going to launch the IDE")
324+
val deploymentURL = wizardModel.coderURL.toURL()
325+
val selectedWorkspace = wizardModel.selectedWorkspace
326+
if (selectedWorkspace == null) {
327+
// TODO: Should be impossible, tweak the types/flow to enforce this.
328+
logger.warn("No workspace was selected. Please go back to the previous step and select a workspace")
329+
return false
330+
}
313331
cs.launch {
314332
GatewayUI.getInstance().connect(
315333
selectedIDE
316334
.toWorkspaceParams()
317-
.withWorkspaceHostname("coder.${wizardModel.selectedWorkspace?.name}")
335+
.withWorkspaceHostname(CoderCLIManager.getHostName(deploymentURL, selectedWorkspace))
318336
.withProjectPath(tfProject.text)
319337
.withWebTerminalLink("${terminalLink.url}")
320338
)

src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,9 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
440440
poller?.cancel()
441441
listTableModelOfWorkspaces.items = emptyList()
442442

443+
val deploymentURL = localWizardModel.coderURL.toURL()
444+
val token = localWizardModel.token
445+
443446
// Authenticate and load in a background process with progress.
444447
// TODO: Make this cancelable.
445448
LifetimeDefinition().launchUnderBackgroundProgress(
@@ -449,35 +452,32 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
449452
) {
450453
try {
451454
this.indicator.text = "Authenticating client..."
452-
authenticate(localWizardModel.coderURL.toURL(), localWizardModel.token)
455+
authenticate(deploymentURL, token)
453456
// Remember these in order to default to them for future attempts.
454-
appPropertiesService.setValue(CODER_URL_KEY, localWizardModel.coderURL)
455-
appPropertiesService.setValue(SESSION_TOKEN, localWizardModel.token)
457+
appPropertiesService.setValue(CODER_URL_KEY, deploymentURL.toString())
458+
appPropertiesService.setValue(SESSION_TOKEN, token)
456459

457460
this.indicator.text = "Retrieving workspaces..."
458461
loadWorkspaces()
459462

460463
this.indicator.text = "Downloading Coder CLI..."
461-
val cliManager = CoderCLIManager(localWizardModel.coderURL.toURL())
464+
val cliManager = CoderCLIManager(deploymentURL)
462465
cliManager.downloadCLI()
463466

464467
this.indicator.text = "Authenticating Coder CLI..."
465-
cliManager.login(localWizardModel.token)
466-
467-
this.indicator.text = "Configuring SSH..."
468-
cliManager.configSsh()
468+
cliManager.login(token)
469469

470470
updateWorkspaceActions()
471471
triggerWorkspacePolling(false)
472472
} catch (e: AuthenticationResponseException) {
473-
logger.error("Token was rejected by ${localWizardModel.coderURL}; has your token expired?", e)
474-
askTokenAndConnect(openBrowser) // Try again but no more opening browser windows.
473+
logger.error("Token was rejected by $deploymentURL; has your token expired?", e)
474+
askTokenAndConnect(openBrowser)
475475
} catch (e: SocketTimeoutException) {
476-
logger.error("Unable to connect to ${localWizardModel.coderURL}; is it up?", e)
476+
logger.error("Unable to connect to $deploymentURL; is it up?", e)
477477
} catch (e: ResponseException) {
478478
logger.error("Failed to download Coder CLI", e)
479479
} catch (e: Exception) {
480-
logger.error("Failed to configure connection to ${localWizardModel.coderURL}", e)
480+
logger.error("Failed to configure connection to $deploymentURL", e)
481481
}
482482
}
483483
}
@@ -705,6 +705,11 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
705705
if (workspace != null) {
706706
wizardModel.selectedWorkspace = workspace
707707
poller?.cancel()
708+
709+
logger.info("Configuring Coder CLI...")
710+
val cliManager = CoderCLIManager(wizardModel.coderURL.toURL())
711+
cliManager.configSsh(listTableModelOfWorkspaces.items)
712+
708713
logger.info("Opening IDE and Project Location window for ${workspace.name}")
709714
return true
710715
}

0 commit comments

Comments
 (0)