Skip to content

Commit 077f903

Browse files
authored
Merge pull request #222 from coder/always-configure
Support multiple deployments
2 parents f0352cf + a631a4e commit 077f903

33 files changed

+649
-117
lines changed

src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider {
2121

2222
override suspend fun connect(parameters: Map<String, String>, requestor: ConnectionRequestor): GatewayConnectionHandle? {
2323
val clientLifetime = LifetimeDefinition()
24+
// TODO: If this fails determine if it is an auth error and if so prompt
25+
// for a new token, configure the CLI, then try again.
2426
clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title"), canBeCancelled = true, isIndeterminate = true, project = null) {
2527
val context = SshMultistagePanelContext(parameters.toHostDeployInputs())
2628
logger.info("Deploying and starting IDE with $context")
@@ -43,4 +45,4 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider {
4345
companion object {
4446
val logger = Logger.getInstance(CoderGatewayConnectionProvider::class.java.simpleName)
4547
}
46-
}
48+
}

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: 144 additions & 19 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,29 @@ import javax.xml.bind.annotation.adapters.HexBinaryAdapter
2324
/**
2425
* Manage the CLI for a single deployment.
2526
*/
26-
class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir: Path = getDataDir()) {
27+
class CoderCLIManager @JvmOverloads constructor(
28+
private val deploymentURL: URL,
29+
destinationDir: Path = getDataDir(),
30+
private val sshConfigPath: Path = Path.of(System.getProperty("user.home")).resolve(".ssh/config"),
31+
) {
2732
private var remoteBinaryUrl: URL
2833
var localBinaryPath: Path
34+
private var coderConfigPath: Path
2935

3036
init {
3137
val binaryName = getCoderCLIForOS(getOS(), getArch())
3238
remoteBinaryUrl = URL(
33-
deployment.protocol,
34-
deployment.host,
35-
deployment.port,
39+
deploymentURL.protocol,
40+
deploymentURL.host,
41+
deploymentURL.port,
3642
"/bin/$binaryName"
3743
)
3844
// Convert IDN to ASCII in case the file system cannot support the
3945
// 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-
localBinaryPath = destinationDir.resolve(subdir).resolve(binaryName)
46+
val host = getSafeHost(deploymentURL)
47+
val subdir = if (deploymentURL.port > 0) "${host}-${deploymentURL.port}" else host
48+
localBinaryPath = destinationDir.resolve(subdir).resolve(binaryName).toAbsolutePath()
49+
coderConfigPath = destinationDir.resolve(subdir).resolve("config").toAbsolutePath()
4350
}
4451

4552
/**
@@ -81,7 +88,7 @@ class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir:
8188
val etag = getBinaryETag()
8289
val conn = remoteBinaryUrl.openConnection() as HttpURLConnection
8390
if (etag != null) {
84-
logger.info("Found existing binary at ${localBinaryPath.toAbsolutePath()}; calculated hash as $etag")
91+
logger.info("Found existing binary at $localBinaryPath; calculated hash as $etag")
8592
conn.setRequestProperty("If-None-Match", "\"$etag\"")
8693
}
8794
conn.setRequestProperty("Accept-Encoding", "gzip")
@@ -91,7 +98,7 @@ class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir:
9198
logger.info("GET ${conn.responseCode} $remoteBinaryUrl")
9299
when (conn.responseCode) {
93100
HttpURLConnection.HTTP_OK -> {
94-
logger.info("Downloading binary to ${localBinaryPath.toAbsolutePath()}")
101+
logger.info("Downloading binary to $localBinaryPath")
95102
Files.createDirectories(localBinaryPath.parent)
96103
conn.inputStream.use {
97104
Files.copy(
@@ -110,7 +117,7 @@ class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir:
110117
}
111118

112119
HttpURLConnection.HTTP_NOT_MODIFIED -> {
113-
logger.info("Using cached binary at ${localBinaryPath.toAbsolutePath()}")
120+
logger.info("Using cached binary at $localBinaryPath")
114121
return false
115122
}
116123
}
@@ -137,26 +144,133 @@ class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir:
137144
} catch (e: FileNotFoundException) {
138145
null
139146
} catch (e: Exception) {
140-
logger.warn("Unable to calculate hash for ${localBinaryPath.toAbsolutePath()}", e)
147+
logger.warn("Unable to calculate hash for $localBinaryPath", e)
141148
null
142149
}
143150
}
144151

145152
/**
146-
* Use the provided credentials to authenticate the CLI.
153+
* Use the provided token to authenticate the CLI.
147154
*/
148-
fun login(url: String, token: String): String {
149-
return exec("login", url, "--token", token)
155+
fun login(token: String): String {
156+
logger.info("Storing CLI credentials in $coderConfigPath")
157+
return exec(
158+
"login",
159+
deploymentURL.toString(),
160+
"--token",
161+
token,
162+
"--global-config",
163+
coderConfigPath.toString(),
164+
)
150165
}
151166

152167
/**
153168
* Configure SSH to use this binary.
154-
*
155-
* TODO: Support multiple deployments; currently they will clobber each
156-
* other.
157169
*/
158-
fun configSsh(): String {
159-
return exec("config-ssh", "--yes", "--use-previous-options")
170+
fun configSsh(workspaces: List<WorkspaceAgentModel>) {
171+
writeSSHConfig(modifySSHConfig(readSSHConfig(), workspaces))
172+
}
173+
174+
/**
175+
* Return the contents of the SSH config or null if it does not exist.
176+
*/
177+
private fun readSSHConfig(): String? {
178+
return try {
179+
sshConfigPath.toFile().readText()
180+
} catch (e: FileNotFoundException) {
181+
null
182+
}
183+
}
184+
185+
/**
186+
* Given an existing SSH config modify it to add or remove the config for
187+
* this deployment and return the modified config or null if it does not
188+
* need to be modified.
189+
*/
190+
private fun modifySSHConfig(contents: String?, workspaces: List<WorkspaceAgentModel>): String? {
191+
val host = getSafeHost(deploymentURL)
192+
val startBlock = "# --- START CODER JETBRAINS $host"
193+
val endBlock = "# --- END CODER JETBRAINS $host"
194+
val isRemoving = workspaces.isEmpty()
195+
val blockContent = workspaces.joinToString(
196+
System.lineSeparator(),
197+
startBlock + System.lineSeparator(),
198+
System.lineSeparator() + endBlock,
199+
transform = {
200+
"""
201+
Host ${getHostName(deploymentURL, it)}
202+
HostName coder.${it.name}
203+
ProxyCommand "$localBinaryPath" --global-config "$coderConfigPath" ssh --stdio ${it.name}
204+
ConnectTimeout 0
205+
StrictHostKeyChecking no
206+
UserKnownHostsFile /dev/null
207+
LogLevel ERROR
208+
SetEnv CODER_SSH_SESSION_TYPE=JetBrains
209+
""".trimIndent().replace("\n", System.lineSeparator())
210+
})
211+
212+
if (contents == null) {
213+
logger.info("No existing SSH config to modify")
214+
return blockContent + System.lineSeparator()
215+
}
216+
217+
val start = "(\\s*)$startBlock".toRegex().find(contents)
218+
val end = "$endBlock(\\s*)".toRegex().find(contents)
219+
220+
if (start == null && end == null && isRemoving) {
221+
logger.info("No workspaces and no existing config blocks to remove")
222+
return null
223+
}
224+
225+
if (start == null && end == null) {
226+
logger.info("Appending config block")
227+
val toAppend = if (contents.isEmpty()) blockContent else listOf(
228+
contents,
229+
blockContent
230+
).joinToString(System.lineSeparator())
231+
return toAppend + System.lineSeparator()
232+
}
233+
234+
if (start == null) {
235+
throw SSHConfigFormatException("End block exists but no start block")
236+
}
237+
if (end == null) {
238+
throw SSHConfigFormatException("Start block exists but no end block")
239+
}
240+
if (start.range.first > end.range.first) {
241+
throw SSHConfigFormatException("Start block found after end block")
242+
}
243+
244+
if (isRemoving) {
245+
logger.info("No workspaces; removing config block")
246+
return listOf(
247+
contents.substring(0, start.range.first),
248+
// Need to keep the trailing newline(s) if we are not at the
249+
// front of the file otherwise the before and after lines would
250+
// get joined.
251+
if (start.range.first > 0) end.groupValues[1] else "",
252+
contents.substring(end.range.last + 1)
253+
).joinToString("")
254+
}
255+
256+
logger.info("Replacing existing config block")
257+
return listOf(
258+
contents.substring(0, start.range.first),
259+
start.groupValues[1], // Leading newline(s).
260+
blockContent,
261+
end.groupValues[1], // Trailing newline(s).
262+
contents.substring(end.range.last + 1)
263+
).joinToString("")
264+
}
265+
266+
/**
267+
* Write the provided SSH config or do nothing if null.
268+
*/
269+
private fun writeSSHConfig(contents: String?) {
270+
if (contents != null) {
271+
Files.createDirectories(sshConfigPath.parent)
272+
sshConfigPath.toFile().writeText(contents)
273+
}
160274
}
161275

162276
/**
@@ -241,6 +355,15 @@ class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir:
241355
}
242356
}
243357
}
358+
359+
private fun getSafeHost(url: URL): String {
360+
return IDN.toASCII(url.host, IDN.ALLOW_UNASSIGNED)
361+
}
362+
363+
@JvmStatic
364+
fun getHostName(url: URL, ws: WorkspaceAgentModel): String {
365+
return "coder-jetbrains--${ws.name}--${getSafeHost(url)}"
366+
}
244367
}
245368
}
246369

@@ -255,3 +378,5 @@ class Environment(private val env: Map<String, String> = emptyMap()) {
255378
}
256379

257380
class ResponseException(message: String, val code: Int) : Exception(message)
381+
382+
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
)

0 commit comments

Comments
 (0)