1
1
package com.coder.gateway.models
2
2
3
+ import com.intellij.openapi.diagnostic.Logger
4
+ import com.intellij.openapi.progress.ProgressIndicator
3
5
import com.intellij.remote.AuthType
4
6
import com.intellij.remote.RemoteCredentialsHolder
5
7
import com.intellij.ssh.config.unified.SshConfig
@@ -9,7 +11,12 @@ import com.jetbrains.gateway.ssh.IdeInfo
9
11
import com.jetbrains.gateway.ssh.IdeWithStatus
10
12
import com.jetbrains.gateway.ssh.IntelliJPlatformProduct
11
13
import com.jetbrains.gateway.ssh.deploy.DeployTargetInfo
14
+ import com.jetbrains.gateway.ssh.deploy.ShellArgument
15
+ import com.jetbrains.gateway.ssh.deploy.TransferProgressTracker
16
+ import com.jetbrains.gateway.ssh.util.validateIDEInstallPath
17
+ import org.zeroturnaround.exec.ProcessExecutor
12
18
import java.net.URI
19
+ import java.time.Duration
13
20
import java.time.LocalDateTime
14
21
import java.time.format.DateTimeFormatter
15
22
@@ -21,88 +28,195 @@ private val localTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm"
21
28
*/
22
29
@Suppress(" UnstableApiUsage" )
23
30
class WorkspaceProjectIDE (
24
- val name : String? ,
31
+ val name : String ,
25
32
val hostname : String ,
26
33
val projectPath : String ,
27
34
val ideProductCode : IntelliJPlatformProduct ,
28
35
val ideBuildNumber : String ,
29
36
30
- // Either a path or URL .
31
- val ideSource : String ,
32
- val isDownloadSource : Boolean ,
37
+ // One of these must exist; enforced by the constructor .
38
+ var idePathOnHost : String? ,
39
+ val downloadSource : String? ,
33
40
34
41
// These are used in the recent connections window.
35
- val webTerminalLink : String? ,
36
- val configDirectory : String? ,
37
- var lastOpened : String? ,
42
+ val deploymentURL : String ,
43
+ var lastOpened : String? , // Null if never opened.
38
44
) {
45
+ val ideName = " ${ideProductCode.productCode} -$ideBuildNumber "
46
+
47
+ private val maxDisplayLength = 35
48
+
39
49
/* *
40
- * Return accessor for deploying the IDE .
50
+ * A shortened path for displaying where space is tight .
41
51
*/
42
- suspend fun toHostDeployInputs (): HostDeployInputs {
52
+ val projectPathDisplay = if (projectPath.length <= maxDisplayLength) projectPath
53
+ else " …" + projectPath.substring(projectPath.length - maxDisplayLength, projectPath.length)
54
+
55
+ init {
56
+ if (idePathOnHost.isNullOrBlank() && downloadSource.isNullOrBlank()) {
57
+ throw Exception (" A path to the IDE on the host or a download source is required" )
58
+ }
59
+ }
60
+
61
+ /* *
62
+ * Return an accessor for connecting to the IDE, deploying it first if
63
+ * necessary. If a deployment was necessary, the IDE path on the host will
64
+ * be updated to reflect the location on disk.
65
+ */
66
+ suspend fun deploy (indicator : ProgressIndicator , timeout : Duration , setupCommand : String ): HostDeployInputs {
43
67
this .lastOpened = localTimeFormatter.format(LocalDateTime .now())
44
- return HostDeployInputs .FullySpecified (
45
- remoteProjectPath = projectPath,
46
- deployTarget = toDeployTargetInfo(),
47
- remoteInfo = HostDeployInputs .WithDeployedWorker (
48
- HighLevelHostAccessor .create(
49
- RemoteCredentialsHolder ().apply {
50
- setHost(hostname)
51
- userName = " coder"
52
- port = 22
53
- authType = AuthType .OPEN_SSH
54
- },
55
- true
56
- ),
57
- HostDeployInputs .WithHostInfo (this .toSshConfig())
58
- )
68
+ indicator.text = " Connecting to remote worker..."
69
+ logger.info(" Connecting to remote worker on $hostname " )
70
+ val accessor = HighLevelHostAccessor .create(
71
+ RemoteCredentialsHolder ().apply {
72
+ setHost(hostname)
73
+ userName = " coder"
74
+ port = 22
75
+ authType = AuthType .OPEN_SSH
76
+ },
77
+ true
59
78
)
60
- }
61
79
62
- private fun toSshConfig (): SshConfig {
63
- return SshConfig (true ).apply {
80
+ // Ensure the IDE exists. If not, download it if we have a download
81
+ // URL. We do this ourselves instead of just giving JetBrains the
82
+ // download source or path and letting them handle it:
83
+ // 1. To get the actual directory into which the IDE is extracted (the
84
+ // postDeployCallback does not give us this information). We want
85
+ // this directory to run a setup script inside it.
86
+ // 2. To provide a better error message when the IDE is gone (JetBrains
87
+ // by default will just hang trying to connect to it).
88
+ // 3. So if the IDE was deleted, we can download it again assuming we
89
+ // stored the original download URL.
90
+ val path: String
91
+ if (idePathOnHost.isNullOrBlank()) {
92
+ logger.info(" No install found for $ideName on $hostname " )
93
+ path = this .doDeploy(accessor, indicator, timeout)
94
+ } else {
95
+ indicator.text = " Verifying remote IDE exists..."
96
+ logger.info(" Verifying $ideName exists at $idePathOnHost on $hostname " )
97
+ val validatedPath = validateIDEInstallPath(idePathOnHost, accessor).pathOrNull
98
+ path = if (validatedPath != null ) {
99
+ logger.info(" $ideName already exists at ${validatedPath.toRawString()} on $hostname " )
100
+ validatedPath.toRawString()
101
+ } else this .doDeploy(accessor, indicator, timeout)
102
+ }
103
+ idePathOnHost = path
104
+
105
+ if (setupCommand.isNotBlank()) {
106
+ // The accessor does not appear to provide a generic exec.
107
+ indicator.text = " Running setup command..."
108
+ logger.info(" Running setup command `$setupCommand ` in $path on $hostname ..." )
109
+ exec(setupCommand)
110
+ } else {
111
+ logger.info(" No setup command to run on $hostname " )
112
+ }
113
+
114
+ val sshConfig = SshConfig (true ).apply {
64
115
setHost(hostname)
65
116
setUsername(" coder" )
66
117
port = 22
67
118
authType = AuthType .OPEN_SSH
68
119
}
69
- }
70
120
71
- private fun toDeployTargetInfo (): DeployTargetInfo {
72
- return if (this .isDownloadSource) DeployTargetInfo .DeployWithDownload (
73
- URI (this .ideSource),
74
- null ,
75
- this .toIdeInfo()
121
+ // This is the configuration that tells JetBrains to connect to the IDE
122
+ // stored at this path. It will spawn the IDE and handle reconnections,
123
+ // but it will not respawn the IDE if it goes away.
124
+ // TODO: We will need to handle the respawn ourselves.
125
+ return HostDeployInputs .FullySpecified (
126
+ remoteProjectPath = projectPath,
127
+ deployTarget = DeployTargetInfo .NoDeploy (path, IdeInfo (
128
+ product = this .ideProductCode,
129
+ buildNumber = this .ideBuildNumber,
130
+ )),
131
+ remoteInfo = HostDeployInputs .WithDeployedWorker (
132
+ accessor,
133
+ HostDeployInputs .WithHostInfo (sshConfig)
134
+ )
76
135
)
77
- else DeployTargetInfo .NoDeploy (this .ideSource, this .toIdeInfo())
78
136
}
79
137
80
- private fun toIdeInfo (): IdeInfo {
81
- return IdeInfo (
82
- product = this .ideProductCode,
83
- buildNumber = this .ideBuildNumber,
84
- )
138
+ /* *
139
+ * Deploy the IDE and return the path to its location on disk.
140
+ */
141
+ private suspend fun doDeploy (accessor : HighLevelHostAccessor , indicator : ProgressIndicator , timeout : Duration ): String {
142
+ if (downloadSource.isNullOrBlank()) {
143
+ throw Exception (" The IDE could not be found on the remote and no download source was provided" )
144
+ }
145
+
146
+ val distDir = accessor.getDefaultDistDir()
147
+
148
+ // HighLevelHostAccessor.downloadFile does NOT create the directory.
149
+ indicator.text = " Creating $distDir ..."
150
+ accessor.createPathOnRemote(distDir)
151
+
152
+ // Download the IDE.
153
+ val fileName = downloadSource.split(" /" ).last()
154
+ val downloadPath = distDir.join(listOf (ShellArgument .PlainText (fileName)))
155
+ indicator.text = " Downloading $ideName ..."
156
+ indicator.text2 = downloadSource
157
+ logger.info(" Downloading $ideName to ${downloadPath.toRawString()} from $downloadSource on $hostname " )
158
+ accessor.downloadFile(indicator, URI (downloadSource), downloadPath, object : TransferProgressTracker {
159
+ override var isCancelled: Boolean = false
160
+ override fun updateProgress (transferred : Long , speed : Long? ) {
161
+ // Since there is no total size, this is useless.
162
+ }
163
+ })
164
+
165
+ // Extract the IDE to its final resting place.
166
+ val ideDir = distDir.join(listOf (ShellArgument .PlainText (ideName)))
167
+ indicator.text = " Extracting $ideName ..."
168
+ logger.info(" Extracting $ideName to ${ideDir.toRawString()} on $hostname " )
169
+ accessor.removePathOnRemote(ideDir)
170
+ accessor.expandArchive(downloadPath, ideDir, timeout.toMillis())
171
+ accessor.removePathOnRemote(downloadPath)
172
+
173
+ // Without this file it does not show up in the installed IDE list.
174
+ val sentinelFile = ideDir.join(listOf (ShellArgument .PlainText (" .expandSucceeded" ))).toRawString()
175
+ logger.info(" Creating $sentinelFile on $hostname " )
176
+ accessor.fileAccessor.uploadFileFromLocalStream(
177
+ sentinelFile,
178
+ " " .byteInputStream(),
179
+ null )
180
+
181
+ logger.info(" Successfully installed ${ideProductCode.productCode} -$ideBuildNumber on $hostname " )
182
+ indicator.text = " Connecting..."
183
+ indicator.text = " "
184
+
185
+ return ideDir.toRawString()
186
+ }
187
+
188
+ /* *
189
+ * Execute a command in the IDE directory.
190
+ */
191
+ private fun exec (command : String ): String {
192
+ return ProcessExecutor ()
193
+ .command(" ssh" , " -t" , hostname, " cd '$idePathOnHost ' ; $command " )
194
+ .exitValues(0 )
195
+ .readOutput(true )
196
+ .execute()
197
+ .outputUTF8()
85
198
}
86
199
87
200
/* *
88
201
* Convert parameters into a recent workspace connection (for storage).
89
202
*/
90
203
fun toRecentWorkspaceConnection (): RecentWorkspaceConnection {
91
204
return RecentWorkspaceConnection (
92
- name = this .name,
93
- coderWorkspaceHostname = this .hostname,
94
- projectPath = this .projectPath,
95
- ideProductCode = this .ideProductCode.productCode,
96
- ideBuildNumber = this .ideBuildNumber,
97
- downloadSource = if (this .isDownloadSource) this .ideSource else " " ,
98
- idePathOnHost = if (this .isDownloadSource) " " else this .ideSource,
99
- lastOpened = this .lastOpened,
100
- webTerminalLink = this .webTerminalLink,
101
- configDirectory = this .configDirectory,
205
+ name = name,
206
+ coderWorkspaceHostname = hostname,
207
+ projectPath = projectPath,
208
+ ideProductCode = ideProductCode.productCode,
209
+ ideBuildNumber = ideBuildNumber,
210
+ downloadSource = downloadSource,
211
+ idePathOnHost = idePathOnHost,
212
+ deploymentURL = deploymentURL,
213
+ lastOpened = lastOpened,
102
214
)
103
215
}
104
216
105
217
companion object {
218
+ val logger = Logger .getInstance(WorkspaceProjectIDE ::class .java.simpleName)
219
+
106
220
/* *
107
221
* Create from unvalidated user inputs.
108
222
*/
@@ -111,38 +225,36 @@ class WorkspaceProjectIDE(
111
225
name : String? ,
112
226
hostname : String? ,
113
227
projectPath : String? ,
228
+ deploymentURL : String? ,
114
229
lastOpened : String? ,
115
230
ideProductCode : String? ,
116
231
ideBuildNumber : String? ,
117
232
downloadSource : String? ,
118
233
idePathOnHost : String? ,
119
- webTerminalLink : String? ,
120
- configDirectory : String? ,
121
234
): WorkspaceProjectIDE {
122
- val ideSource = if (idePathOnHost.isNullOrBlank()) downloadSource else idePathOnHost
123
- if (hostname.isNullOrBlank()) {
124
- throw Error (" host name is missing" )
235
+ if (name.isNullOrBlank()) {
236
+ throw Exception (" Workspace name is missing" )
237
+ } else if (deploymentURL.isNullOrBlank()) {
238
+ throw Exception (" Deployment URL is missing" )
239
+ } else if (hostname.isNullOrBlank()) {
240
+ throw Exception (" Host name is missing" )
125
241
} else if (projectPath.isNullOrBlank()) {
126
- throw Error ( " project path is missing" )
242
+ throw Exception ( " Project path is missing" )
127
243
} else if (ideProductCode.isNullOrBlank()) {
128
- throw Error ( " ide product code is missing" )
244
+ throw Exception ( " IDE product code is missing" )
129
245
} else if (ideBuildNumber.isNullOrBlank()) {
130
- throw Error (" ide build number is missing" )
131
- } else if (ideSource.isNullOrBlank()) {
132
- throw Error (" one of path or download is required" )
246
+ throw Exception (" IDE build number is missing" )
133
247
}
134
248
135
249
return WorkspaceProjectIDE (
136
250
name = name,
137
251
hostname = hostname,
138
252
projectPath = projectPath,
139
- ideProductCode = IntelliJPlatformProduct .fromProductCode(ideProductCode) ? : throw Error (" invalid product code" ),
253
+ ideProductCode = IntelliJPlatformProduct .fromProductCode(ideProductCode) ? : throw Exception (" invalid product code" ),
140
254
ideBuildNumber = ideBuildNumber,
141
- webTerminalLink = webTerminalLink,
142
- configDirectory = configDirectory,
143
-
144
- ideSource = ideSource,
145
- isDownloadSource = idePathOnHost.isNullOrBlank(),
255
+ idePathOnHost = idePathOnHost,
256
+ downloadSource = downloadSource,
257
+ deploymentURL = deploymentURL,
146
258
lastOpened = lastOpened,
147
259
)
148
260
}
@@ -160,10 +272,9 @@ fun RecentWorkspaceConnection.toWorkspaceProjectIDE(): WorkspaceProjectIDE {
160
272
projectPath = projectPath,
161
273
ideProductCode = ideProductCode,
162
274
ideBuildNumber = ideBuildNumber,
163
- webTerminalLink = webTerminalLink,
164
- configDirectory = configDirectory,
165
275
idePathOnHost = idePathOnHost,
166
276
downloadSource = downloadSource,
277
+ deploymentURL = deploymentURL,
167
278
lastOpened = lastOpened,
168
279
)
169
280
}
@@ -176,26 +287,25 @@ fun IdeWithStatus.withWorkspaceProject(
176
287
name : String ,
177
288
hostname : String ,
178
289
projectPath : String ,
179
- webTerminalLink : String ,
180
- configDirectory : String ,
290
+ deploymentURL : String ,
181
291
): WorkspaceProjectIDE {
182
- val download = this .download
183
- val pathOnHost = this .pathOnHost
184
- val ideSource = if (pathOnHost.isNullOrBlank()) download?.link else pathOnHost
185
- if (ideSource.isNullOrBlank()) {
186
- throw Error (" one of path or download is required" )
187
- }
188
292
return WorkspaceProjectIDE (
189
293
name = name,
190
294
hostname = hostname,
191
295
projectPath = projectPath,
192
296
ideProductCode = this .product,
193
297
ideBuildNumber = this .buildNumber,
194
- webTerminalLink = webTerminalLink,
195
- configDirectory = configDirectory,
196
-
197
- ideSource = ideSource,
198
- isDownloadSource = pathOnHost.isNullOrBlank(),
298
+ downloadSource = this .download?.link,
299
+ idePathOnHost = this .pathOnHost,
300
+ deploymentURL = deploymentURL,
199
301
lastOpened = null ,
200
302
)
201
303
}
304
+
305
+ val remotePathRe = Regex (" ^[^(]+\\ ((.+)\\ )$" )
306
+ fun ShellArgument.RemotePath.toRawString (): String {
307
+ // TODO: Surely there is an actual way to do this.
308
+ val remotePath = flatten().toString()
309
+ return remotePathRe.find(remotePath)?.groupValues?.get(1 )
310
+ ? : throw Exception (" Got invalid path $remotePath " )
311
+ }
0 commit comments