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,29 @@ 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(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
+ ) {
27
32
private var remoteBinaryUrl: URL
28
33
var localBinaryPath: Path
34
+ private var coderConfigPath: Path
29
35
30
36
init {
31
37
val binaryName = getCoderCLIForOS(getOS(), getArch())
32
38
remoteBinaryUrl = URL (
33
- deployment .protocol,
34
- deployment .host,
35
- deployment .port,
39
+ deploymentURL .protocol,
40
+ deploymentURL .host,
41
+ deploymentURL .port,
36
42
" /bin/$binaryName "
37
43
)
38
44
// Convert IDN to ASCII in case the file system cannot support the
39
45
// 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()
43
50
}
44
51
45
52
/* *
@@ -81,7 +88,7 @@ class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir:
81
88
val etag = getBinaryETag()
82
89
val conn = remoteBinaryUrl.openConnection() as HttpURLConnection
83
90
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 " )
85
92
conn.setRequestProperty(" If-None-Match" , " \" $etag \" " )
86
93
}
87
94
conn.setRequestProperty(" Accept-Encoding" , " gzip" )
@@ -91,7 +98,7 @@ class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir:
91
98
logger.info(" GET ${conn.responseCode} $remoteBinaryUrl " )
92
99
when (conn.responseCode) {
93
100
HttpURLConnection .HTTP_OK -> {
94
- logger.info(" Downloading binary to ${ localBinaryPath.toAbsolutePath()} " )
101
+ logger.info(" Downloading binary to $localBinaryPath " )
95
102
Files .createDirectories(localBinaryPath.parent)
96
103
conn.inputStream.use {
97
104
Files .copy(
@@ -110,7 +117,7 @@ class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir:
110
117
}
111
118
112
119
HttpURLConnection .HTTP_NOT_MODIFIED -> {
113
- logger.info(" Using cached binary at ${ localBinaryPath.toAbsolutePath()} " )
120
+ logger.info(" Using cached binary at $localBinaryPath " )
114
121
return false
115
122
}
116
123
}
@@ -137,26 +144,133 @@ class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir:
137
144
} catch (e: FileNotFoundException ) {
138
145
null
139
146
} catch (e: Exception ) {
140
- logger.warn(" Unable to calculate hash for ${ localBinaryPath.toAbsolutePath()} " , e)
147
+ logger.warn(" Unable to calculate hash for $localBinaryPath " , e)
141
148
null
142
149
}
143
150
}
144
151
145
152
/* *
146
- * Use the provided credentials to authenticate the CLI.
153
+ * Use the provided token to authenticate the CLI.
147
154
*/
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
+ )
150
165
}
151
166
152
167
/* *
153
168
* Configure SSH to use this binary.
154
- *
155
- * TODO: Support multiple deployments; currently they will clobber each
156
- * other.
157
169
*/
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
+ }
160
274
}
161
275
162
276
/* *
@@ -241,6 +355,15 @@ class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir:
241
355
}
242
356
}
243
357
}
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
+ }
244
367
}
245
368
}
246
369
@@ -255,3 +378,5 @@ class Environment(private val env: Map<String, String> = emptyMap()) {
255
378
}
256
379
257
380
class ResponseException (message : String , val code : Int ) : Exception(message)
381
+
382
+ class SSHConfigFormatException (message : String ) : Exception(message)
0 commit comments