11package com.coder.gateway.sdk
22
3+ import com.coder.gateway.models.WorkspaceAgentModel
34import com.coder.gateway.views.steps.CoderWorkspacesStepView
45import com.intellij.openapi.diagnostic.Logger
56import 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
257345class ResponseException (message : String , val code : Int ) : Exception(message)
346+
347+ class SSHConfigFormatException (message : String ) : Exception(message)
0 commit comments