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