3
3
package com.coder.gateway
4
4
5
5
import com.coder.gateway.models.WorkspaceProjectIDE
6
+ import com.coder.gateway.models.toRawString
6
7
import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService
7
8
import com.coder.gateway.services.CoderSettingsService
8
9
import com.coder.gateway.settings.CoderSettings
@@ -21,86 +22,97 @@ import com.intellij.openapi.progress.ProgressIndicator
21
22
import com.intellij.openapi.rd.util.launchUnderBackgroundProgress
22
23
import com.intellij.openapi.ui.Messages
23
24
import com.intellij.openapi.ui.panel.ComponentPanelBuilder
25
+ import com.intellij.remote.AuthType
26
+ import com.intellij.remote.RemoteCredentialsHolder
27
+ import com.intellij.remoteDev.hostStatus.UnattendedHostStatus
24
28
import com.intellij.ui.AppIcon
25
29
import com.intellij.ui.components.JBTextField
26
30
import com.intellij.ui.components.dialog
27
31
import com.intellij.ui.dsl.builder.RowLayout
28
32
import com.intellij.ui.dsl.builder.panel
29
33
import com.intellij.util.applyIf
30
34
import com.intellij.util.ui.UIUtil
31
- import com.jetbrains.gateway.ssh.SshDeployFlowUtil
32
- import com.jetbrains.gateway.ssh.SshMultistagePanelContext
35
+ import com.jetbrains.gateway.ssh.ClientOverSshTunnelConnector
36
+ import com.jetbrains.gateway.ssh.HighLevelHostAccessor
37
+ import com.jetbrains.gateway.ssh.SshHostTunnelConnector
33
38
import com.jetbrains.gateway.ssh.deploy.DeployException
39
+ import com.jetbrains.gateway.ssh.deploy.ShellArgument
40
+ import com.jetbrains.gateway.ssh.deploy.TransferProgressTracker
41
+ import com.jetbrains.gateway.ssh.util.validateIDEInstallPath
34
42
import com.jetbrains.rd.util.lifetime.LifetimeDefinition
43
+ import com.jetbrains.rd.util.lifetime.LifetimeStatus
44
+ import kotlinx.coroutines.delay
45
+ import kotlinx.coroutines.isActive
46
+ import kotlinx.coroutines.launch
47
+ import kotlinx.coroutines.suspendCancellableCoroutine
35
48
import net.schmizz.sshj.common.SSHException
36
49
import net.schmizz.sshj.connection.ConnectionException
50
+ import org.zeroturnaround.exec.ProcessExecutor
37
51
import java.awt.Dimension
38
52
import java.net.HttpURLConnection
53
+ import java.net.URI
39
54
import java.net.URL
40
55
import java.time.Duration
56
+ import java.time.LocalDateTime
57
+ import java.time.format.DateTimeFormatter
41
58
import java.util.concurrent.TimeoutException
42
59
import javax.net.ssl.SSLHandshakeException
60
+ import kotlin.coroutines.resume
61
+ import kotlin.coroutines.resumeWithException
43
62
44
63
// CoderRemoteConnection uses the provided workspace SSH parameters to launch an
45
64
// IDE against the workspace. If successful the connection is added to recent
46
65
// connections.
66
+ @Suppress(" UnstableApiUsage" )
47
67
class CoderRemoteConnectionHandle {
48
68
private val recentConnectionsService = service<CoderRecentWorkspaceConnectionsService >()
49
69
private val settings = service<CoderSettingsService >()
50
70
71
+ private val localTimeFormatter = DateTimeFormatter .ofPattern(" yyyy-MMM-dd HH:mm" )
72
+
51
73
fun connect (getParameters : (indicator: ProgressIndicator ) -> WorkspaceProjectIDE ) {
52
74
val clientLifetime = LifetimeDefinition ()
53
75
clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle .message(" gateway.connector.coder.connection.provider.title" )) {
54
76
try {
55
77
val parameters = getParameters(indicator)
56
78
logger.debug(" Creating connection handle" , parameters)
57
79
indicator.text = CoderGatewayBundle .message(" gateway.connector.coder.connecting" )
58
- val context =
59
- suspendingRetryWithExponentialBackOff(
60
- action = { attempt ->
61
- logger.info(" Connecting... (attempt $attempt )" )
62
- if (attempt > 1 ) {
63
- // indicator.text is the text above the progress bar.
64
- indicator.text = CoderGatewayBundle .message(" gateway.connector.coder.connecting.retry" , attempt)
80
+ suspendingRetryWithExponentialBackOff(
81
+ action = { attempt ->
82
+ logger.info(" Connecting... (attempt $attempt )" )
83
+ if (attempt > 1 ) {
84
+ // indicator.text is the text above the progress bar.
85
+ indicator.text = CoderGatewayBundle .message(" gateway.connector.coder.connecting.retry" , attempt)
86
+ }
87
+ doConnect(
88
+ parameters,
89
+ indicator,
90
+ clientLifetime,
91
+ settings.setupCommand,
92
+ settings.ignoreSetupFailure,
93
+ )
94
+ },
95
+ retryIf = {
96
+ it is ConnectionException || it is TimeoutException ||
97
+ it is SSHException || it is DeployException
98
+ },
99
+ onException = { attempt, nextMs, e ->
100
+ logger.error(" Failed to connect (attempt $attempt ; will retry in $nextMs ms)" )
101
+ // indicator.text2 is the text below the progress bar.
102
+ indicator.text2 =
103
+ if (isWorkerTimeout(e)) {
104
+ " Failed to upload worker binary...it may have timed out"
105
+ } else {
106
+ e.message ? : e.javaClass.simpleName
65
107
}
66
- val deployInputs =
67
- parameters.deploy(
68
- indicator,
69
- Duration .ofMinutes(10 ),
70
- settings.setupCommand,
71
- settings.ignoreSetupFailure,
72
- )
73
- SshMultistagePanelContext (deployInputs)
74
- },
75
- retryIf = {
76
- it is ConnectionException || it is TimeoutException ||
77
- it is SSHException || it is DeployException
78
- },
79
- onException = { attempt, nextMs, e ->
80
- logger.error(" Failed to connect (attempt $attempt ; will retry in $nextMs ms)" )
81
- // indicator.text2 is the text below the progress bar.
82
- indicator.text2 =
83
- if (isWorkerTimeout(e)) {
84
- " Failed to upload worker binary...it may have timed out"
85
- } else {
86
- e.message ? : e.javaClass.simpleName
87
- }
88
- },
89
- onCountdown = { remainingMs ->
90
- indicator.text =
91
- CoderGatewayBundle .message(
92
- " gateway.connector.coder.connecting.failed.retry" ,
93
- humanizeDuration(remainingMs),
94
- )
95
- },
96
- )
97
- logger.info(" Starting and connecting to ${parameters.ideName} with $context " )
98
- // At this point JetBrains takes over with their own UI.
99
- @Suppress(" UnstableApiUsage" )
100
- SshDeployFlowUtil .fullDeployCycle(
101
- clientLifetime,
102
- context,
103
- Duration .ofMinutes(10 ),
108
+ },
109
+ onCountdown = { remainingMs ->
110
+ indicator.text =
111
+ CoderGatewayBundle .message(
112
+ " gateway.connector.coder.connecting.failed.retry" ,
113
+ humanizeDuration(remainingMs),
114
+ )
115
+ },
104
116
)
105
117
logger.info(" Adding ${parameters.ideName} for ${parameters.hostname} :${parameters.projectPath} to recent connections" )
106
118
recentConnectionsService.addRecentConnection(parameters.toRecentWorkspaceConnection())
@@ -123,6 +135,278 @@ class CoderRemoteConnectionHandle {
123
135
}
124
136
}
125
137
138
+ /* *
139
+ * Deploy (if needed), connect to the IDE, and update the last opened date.
140
+ */
141
+ private suspend fun doConnect (
142
+ workspace : WorkspaceProjectIDE ,
143
+ indicator : ProgressIndicator ,
144
+ lifetime : LifetimeDefinition ,
145
+ setupCommand : String ,
146
+ ignoreSetupFailure : Boolean ,
147
+ timeout : Duration = Duration .ofMinutes(10),
148
+ ): Unit {
149
+ workspace.lastOpened = localTimeFormatter.format(LocalDateTime .now())
150
+
151
+ // This establishes an SSH connection to a remote worker binary.
152
+ // TODO: Can/should accessors to the same host be shared?
153
+ indicator.text = " Connecting to remote worker..."
154
+ logger.info(" Connecting to remote worker on ${workspace.hostname} " )
155
+ val credentials = RemoteCredentialsHolder ().apply {
156
+ setHost(workspace.hostname)
157
+ userName = " coder"
158
+ port = 22
159
+ authType = AuthType .OPEN_SSH
160
+ }
161
+ val accessor = HighLevelHostAccessor .create(credentials, true )
162
+
163
+ // Deploy if we need to.
164
+ val ideDir = this .deploy(workspace, accessor, indicator, timeout)
165
+ workspace.idePathOnHost = ideDir.toRawString()
166
+
167
+ // Run the setup command.
168
+ this .setup(workspace, indicator, setupCommand, ignoreSetupFailure)
169
+
170
+ // Wait for the IDE to come up.
171
+ var status: UnattendedHostStatus ? = null
172
+ val remoteProjectPath = accessor.makeRemotePath(ShellArgument .PlainText (workspace.projectPath))
173
+ val logsDir = accessor.getLogsDir(workspace.ideProductCode.productCode, remoteProjectPath)
174
+ while (lifetime.status == LifetimeStatus .Alive ) {
175
+ status = ensureIDEBackend(workspace, accessor, ideDir, remoteProjectPath, logsDir, lifetime, null )
176
+ if (! status?.joinLink.isNullOrBlank()) {
177
+ break
178
+ }
179
+ delay(5000 )
180
+ }
181
+
182
+ // We wait for non-null, so this only happens on cancellation.
183
+ val joinLink = status?.joinLink
184
+ if (joinLink.isNullOrBlank()) {
185
+ logger.info(" Connection to ${workspace.ideName} on ${workspace.hostname} was canceled" )
186
+ return
187
+ }
188
+
189
+ // Make the initial connection.
190
+ indicator.text = " Connecting ${workspace.ideName} client..."
191
+ logger.info(" Connecting ${workspace.ideName} client to coder@${workspace.hostname} :22" )
192
+ val client = ClientOverSshTunnelConnector (lifetime, SshHostTunnelConnector (credentials))
193
+ val handle = client.connect(URI (joinLink)) // Downloads the client too, if needed.
194
+
195
+ // Reconnect if the join link changes.
196
+ // TODO: Restart the backend if it stops.
197
+ logger.info(" Got ${workspace.ideName} client; beginning backend monitoring" )
198
+ lifetime.coroutineScope.launch {
199
+ while (isActive) {
200
+ delay(5000 )
201
+ val newStatus = ensureIDEBackend(workspace, accessor, ideDir, remoteProjectPath, logsDir, lifetime, status)
202
+ val newLink = newStatus?.joinLink
203
+ if (newLink != null && newLink != status?.joinLink) {
204
+ logger.info(" ${workspace.ideName} backend join link changed; updating" )
205
+ // Unfortunately, updating the link is not a smooth
206
+ // reconnection. The client closes and is relaunched.
207
+ // Trying to reconnect without updating the link results in
208
+ // a fingerprint mismatch error.
209
+ handle.updateJoinLink(URI (newLink), true )
210
+ status = newStatus
211
+ }
212
+ }
213
+ }
214
+
215
+ // Tie the lifetime and client together, and wait for the initial open.
216
+ suspendCancellableCoroutine { continuation ->
217
+ // Close the client if the user cancels.
218
+ lifetime.onTermination {
219
+ logger.info(" Connection to ${workspace.ideName} on ${workspace.hostname} canceled" )
220
+ if (continuation.isActive) {
221
+ continuation.cancel()
222
+ }
223
+ handle.close()
224
+ }
225
+ // Kill the lifetime if the client is closed by the user.
226
+ handle.clientClosed.advise(lifetime) {
227
+ logger.info(" ${workspace.ideName} client ${workspace.hostname} closed" )
228
+ if (lifetime.status == LifetimeStatus .Alive ) {
229
+ if (continuation.isActive) {
230
+ continuation.resumeWithException(Exception (" ${workspace.ideName} client was closed" ))
231
+ }
232
+ lifetime.terminate()
233
+ }
234
+ }
235
+ // Continue once the client is present.
236
+ handle.onClientPresenceChanged.advise(lifetime) {
237
+ if (handle.clientPresent && continuation.isActive) {
238
+ continuation.resume(true )
239
+ }
240
+ }
241
+ }
242
+
243
+ // The presence handler runs a good deal earlier than the client
244
+ // actually appears, which results in some dead space where it can look
245
+ // like opening the client silently failed. This delay janks around
246
+ // that, so we can keep the progress indicator open a bit longer.
247
+ delay(5000 )
248
+ }
249
+
250
+ /* *
251
+ * Deploy the IDE if necessary and return the path to its location on disk.
252
+ */
253
+ private suspend fun deploy (
254
+ workspace : WorkspaceProjectIDE ,
255
+ accessor : HighLevelHostAccessor ,
256
+ indicator : ProgressIndicator ,
257
+ timeout : Duration ,
258
+ ): ShellArgument .RemotePath {
259
+ // The backend might already exist at the provided path.
260
+ if (! workspace.idePathOnHost.isNullOrBlank()) {
261
+ indicator.text = " Verifying ${workspace.ideName} installation..."
262
+ logger.info(" Verifying ${workspace.ideName} exists at ${workspace.hostname} :${workspace.idePathOnHost} " )
263
+ val validatedPath = validateIDEInstallPath(workspace.idePathOnHost, accessor).pathOrNull
264
+ if (validatedPath != null ) {
265
+ logger.info(" ${workspace.ideName} exists at ${workspace.hostname} :${validatedPath.toRawString()} " )
266
+ return validatedPath
267
+ }
268
+ }
269
+
270
+ // The backend might already be installed somewhere on the system.
271
+ indicator.text = " Searching for ${workspace.ideName} installation..."
272
+ logger.info(" Searching for ${workspace.ideName} on ${workspace.hostname} " )
273
+ val installed =
274
+ accessor.getInstalledIDEs().find {
275
+ it.product == workspace.ideProductCode && it.buildNumber == workspace.ideBuildNumber
276
+ }
277
+ if (installed != null ) {
278
+ logger.info(" ${workspace.ideName} found at ${workspace.hostname} :${installed.pathToIde} " )
279
+ return accessor.makeRemotePath(ShellArgument .PlainText (installed.pathToIde))
280
+ }
281
+
282
+ // Otherwise we have to download it.
283
+ if (workspace.downloadSource.isNullOrBlank()) {
284
+ throw Exception (" ${workspace.ideName} could not be found on the remote and no download source was provided" )
285
+ }
286
+
287
+ // TODO: Should we download to idePathOnHost if set? That would require
288
+ // symlinking instead of creating the sentinel file if the path is
289
+ // outside the default dist directory.
290
+ indicator.text = " Downloading ${workspace.ideName} ..."
291
+ indicator.text2 = workspace.downloadSource
292
+ val distDir = accessor.getDefaultDistDir()
293
+
294
+ // HighLevelHostAccessor.downloadFile does NOT create the directory.
295
+ logger.info(" Creating ${workspace.hostname} :${distDir.toRawString()} " )
296
+ accessor.createPathOnRemote(distDir)
297
+
298
+ // Download the IDE.
299
+ val fileName = workspace.downloadSource.split(" /" ).last()
300
+ val downloadPath = distDir.join(listOf (ShellArgument .PlainText (fileName)))
301
+ logger.info(" Downloading ${workspace.ideName} to ${workspace.hostname} :${downloadPath.toRawString()} from ${workspace.downloadSource} " )
302
+ accessor.downloadFile(
303
+ indicator,
304
+ URI (workspace.downloadSource),
305
+ downloadPath,
306
+ object : TransferProgressTracker {
307
+ override var isCancelled: Boolean = false
308
+
309
+ override fun updateProgress (
310
+ transferred : Long ,
311
+ speed : Long? ,
312
+ ) {
313
+ // Since there is no total size, this is useless.
314
+ }
315
+ },
316
+ )
317
+
318
+ // Extract the IDE to its final resting place.
319
+ val ideDir = distDir.join(listOf (ShellArgument .PlainText (workspace.ideName)))
320
+ indicator.text = " Extracting ${workspace.ideName} ..."
321
+ indicator.text2 = " "
322
+ logger.info(" Extracting ${workspace.ideName} to ${workspace.hostname} :${ideDir.toRawString()} " )
323
+ accessor.removePathOnRemote(ideDir)
324
+ accessor.expandArchive(downloadPath, ideDir, timeout.toMillis())
325
+ accessor.removePathOnRemote(downloadPath)
326
+
327
+ // Without this file it does not show up in the installed IDE list.
328
+ val sentinelFile = ideDir.join(listOf (ShellArgument .PlainText (" .expandSucceeded" ))).toRawString()
329
+ logger.info(" Creating ${workspace.hostname} :$sentinelFile " )
330
+ accessor.fileAccessor.uploadFileFromLocalStream(
331
+ sentinelFile,
332
+ " " .byteInputStream(),
333
+ null ,
334
+ )
335
+
336
+ logger.info(" Successfully installed ${workspace.ideName} on ${workspace.hostname} " )
337
+ return ideDir
338
+ }
339
+
340
+ /* *
341
+ * Run the setup command in the IDE's bin directory.
342
+ */
343
+ private fun setup (
344
+ workspace : WorkspaceProjectIDE ,
345
+ indicator : ProgressIndicator ,
346
+ setupCommand : String ,
347
+ ignoreSetupFailure : Boolean ,
348
+ ) {
349
+ if (setupCommand.isNotBlank()) {
350
+ indicator.text = " Running setup command..."
351
+ try {
352
+ exec(workspace, setupCommand)
353
+ } catch (ex: Exception ) {
354
+ if (! ignoreSetupFailure) {
355
+ throw ex
356
+ }
357
+ }
358
+ } else {
359
+ logger.info(" No setup command to run on ${workspace.hostname} " )
360
+ }
361
+ }
362
+
363
+ /* *
364
+ * Execute a command in the IDE's bin directory.
365
+ * This exists since the accessor does not provide a generic exec.
366
+ */
367
+ private fun exec (workspace : WorkspaceProjectIDE , command : String ): String {
368
+ logger.info(" Running command `$command ` in ${workspace.hostname} :${workspace.idePathOnHost} /bin..." )
369
+ return ProcessExecutor ()
370
+ .command(" ssh" , " -t" , workspace.hostname, " cd '${workspace.idePathOnHost} ' ; cd bin ; $command " )
371
+ .exitValues(0 )
372
+ .readOutput(true )
373
+ .execute()
374
+ .outputUTF8()
375
+ }
376
+
377
+ /* *
378
+ * Ensure the backend is started. Link is null if not ready to join.
379
+ */
380
+ private suspend fun ensureIDEBackend (
381
+ workspace : WorkspaceProjectIDE ,
382
+ accessor : HighLevelHostAccessor ,
383
+ ideDir : ShellArgument .RemotePath ,
384
+ remoteProjectPath : ShellArgument .RemotePath ,
385
+ logsDir : ShellArgument .RemotePath ,
386
+ lifetime : LifetimeDefinition ,
387
+ currentStatus : UnattendedHostStatus ? ,
388
+ ): UnattendedHostStatus ? {
389
+ return try {
390
+ // Start the backend if not running.
391
+ val currentPid = currentStatus?.appPid
392
+ if (currentPid == null || ! accessor.isPidAlive(currentPid.toInt())) {
393
+ logger.info(" Starting ${workspace.ideName} backend from ${workspace.hostname} :${ideDir.toRawString()} , project=${remoteProjectPath.toRawString()} , logs=${logsDir.toRawString()} " )
394
+ // This appears to be idempotent.
395
+ accessor.startHostIdeInBackgroundAndDetach(lifetime, ideDir, remoteProjectPath, logsDir)
396
+ } else if (! currentStatus.joinLink.isNullOrBlank()) {
397
+ // We already have a valid join link.
398
+ return currentStatus
399
+ }
400
+ // Get new PID and join link.
401
+ val status = accessor.getHostIdeStatus(ideDir, remoteProjectPath)
402
+ logger.info(" Got ${workspace.ideName} status from ${workspace.hostname} :${ideDir.toRawString()} , pid=${status.appPid} project=${remoteProjectPath.toRawString()} joinLink=${status.joinLink} " )
403
+ status
404
+ } catch (ex: Exception ) {
405
+ logger.info(" Failed to get ${workspace.ideName} status from ${workspace.hostname} :${ideDir.toRawString()} , project=${remoteProjectPath.toRawString()} " , ex)
406
+ null
407
+ }
408
+ }
409
+
126
410
companion object {
127
411
val logger = Logger .getInstance(CoderRemoteConnectionHandle ::class .java.simpleName)
128
412
0 commit comments