1
1
package com.coder.gateway.sdk
2
2
3
+ import com.coder.gateway.models.WorkspaceAgentModel
3
4
import com.coder.gateway.views.steps.CoderWorkspacesStepView
4
5
import com.intellij.openapi.diagnostic.Logger
5
6
import org.zeroturnaround.exec.ProcessExecutor
@@ -23,23 +24,25 @@ import javax.xml.bind.annotation.adapters.HexBinaryAdapter
23
24
/* *
24
25
* Manage the CLI for a single deployment.
25
26
*/
26
- class CoderCLIManager @JvmOverloads constructor(private val deployment : URL , destinationDir : Path = getDataDir()) {
27
+ class CoderCLIManager @JvmOverloads constructor(private val deploymentURL : URL , destinationDir : Path = getDataDir()) {
27
28
private var remoteBinaryUrl: URL
28
29
var localBinaryPath: Path
30
+ private var coderConfigPath: Path
29
31
30
32
init {
31
33
val binaryName = getCoderCLIForOS(getOS(), getArch())
32
34
remoteBinaryUrl = URL (
33
- deployment .protocol,
34
- deployment .host,
35
- deployment .port,
35
+ deploymentURL .protocol,
36
+ deploymentURL .host,
37
+ deploymentURL .port,
36
38
" /bin/$binaryName "
37
39
)
38
40
// Convert IDN to ASCII in case the file system cannot support the
39
41
// 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
42
44
localBinaryPath = destinationDir.resolve(subdir).resolve(binaryName)
45
+ coderConfigPath = destinationDir.resolve(subdir).resolve(" config" )
43
46
}
44
47
45
48
/* *
@@ -146,17 +149,93 @@ class CoderCLIManager @JvmOverloads constructor(private val deployment: URL, des
146
149
* Use the provided token to authenticate the CLI.
147
150
*/
148
151
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
+ )
150
161
}
151
162
152
163
/* *
153
164
* Configure SSH to use this binary.
154
- *
155
- * TODO: Support multiple deployments; currently they will clobber each
156
- * other.
157
165
*/
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
+ }
160
239
}
161
240
162
241
/* *
@@ -241,6 +320,15 @@ class CoderCLIManager @JvmOverloads constructor(private val deployment: URL, des
241
320
}
242
321
}
243
322
}
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
+ }
244
332
}
245
333
}
246
334
@@ -255,3 +343,5 @@ class Environment(private val env: Map<String, String> = emptyMap()) {
255
343
}
256
344
257
345
class ResponseException (message : String , val code : Int ) : Exception(message)
346
+
347
+ class SSHConfigFormatException (message : String ) : Exception(message)
0 commit comments