Skip to content

Commit aef863a

Browse files
committedApr 22, 2024
Add setup command
To do this, we now handle the download of the IDE so we can figure out exactly where it is without having to scan through the list of installed IDEs afterward and match on the IDE product and build number. This has some other advantages as well since the default deploy mechanism does not appear to be very robust. Closes #386
1 parent 9cda2e1 commit aef863a

File tree

6 files changed

+217
-82
lines changed

6 files changed

+217
-82
lines changed
 

‎src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package com.coder.gateway
55
import com.coder.gateway.models.TokenSource
66
import com.coder.gateway.models.WorkspaceProjectIDE
77
import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService
8+
import com.coder.gateway.services.CoderSettingsService
89
import com.coder.gateway.settings.CoderSettings
910
import com.coder.gateway.util.humanizeDuration
1011
import com.coder.gateway.util.isCancellation
@@ -45,6 +46,7 @@ import javax.net.ssl.SSLHandshakeException
4546
// connections.
4647
class CoderRemoteConnectionHandle {
4748
private val recentConnectionsService = service<CoderRecentWorkspaceConnectionsService>()
49+
private val settings = service<CoderSettingsService>()
4850

4951
fun connect(getParameters: (indicator: ProgressIndicator) -> WorkspaceProjectIDE) {
5052
val clientLifetime = LifetimeDefinition()
@@ -55,12 +57,13 @@ class CoderRemoteConnectionHandle {
5557
indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting")
5658
val context = suspendingRetryWithExponentialBackOff(
5759
action = { attempt ->
58-
logger.info("Connecting... (attempt $attempt")
60+
logger.info("Connecting... (attempt $attempt)")
5961
if (attempt > 1) {
6062
// indicator.text is the text above the progress bar.
6163
indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting.retry", attempt)
6264
}
63-
SshMultistagePanelContext(parameters.toHostDeployInputs())
65+
val deployInputs = parameters.deploy(indicator, Duration.ofMinutes(10), settings.setupCommand)
66+
SshMultistagePanelContext(deployInputs)
6467
},
6568
retryIf = {
6669
it is ConnectionException || it is TimeoutException

‎src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,13 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") {
117117
CoderGatewayBundle.message("gateway.connector.settings.ssh-config-options.comment", CODER_SSH_CONFIG_OPTIONS)
118118
)
119119
}.layout(RowLayout.PARENT_GRID)
120+
row(CoderGatewayBundle.message("gateway.connector.settings.setup-command.title")) {
121+
textField().resizableColumn().align(AlignX.FILL)
122+
.bindText(state::setupCommand)
123+
.comment(
124+
CoderGatewayBundle.message("gateway.connector.settings.setup-command.comment")
125+
)
126+
}.layout(RowLayout.PARENT_GRID)
120127
}
121128
}
122129

Lines changed: 190 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.coder.gateway.models
22

3+
import com.intellij.openapi.diagnostic.Logger
4+
import com.intellij.openapi.progress.ProgressIndicator
35
import com.intellij.remote.AuthType
46
import com.intellij.remote.RemoteCredentialsHolder
57
import com.intellij.ssh.config.unified.SshConfig
@@ -9,7 +11,12 @@ import com.jetbrains.gateway.ssh.IdeInfo
911
import com.jetbrains.gateway.ssh.IdeWithStatus
1012
import com.jetbrains.gateway.ssh.IntelliJPlatformProduct
1113
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
1218
import java.net.URI
19+
import java.time.Duration
1320
import java.time.LocalDateTime
1421
import java.time.format.DateTimeFormatter
1522

@@ -21,88 +28,195 @@ private val localTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm"
2128
*/
2229
@Suppress("UnstableApiUsage")
2330
class WorkspaceProjectIDE(
24-
val name: String?,
31+
val name: String,
2532
val hostname: String,
2633
val projectPath: String,
2734
val ideProductCode: IntelliJPlatformProduct,
2835
val ideBuildNumber: String,
2936

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?,
3340

3441
// 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.
3844
) {
45+
val ideName = "${ideProductCode.productCode}-$ideBuildNumber"
46+
47+
private val maxDisplayLength = 35
48+
3949
/**
40-
* Return accessor for deploying the IDE.
50+
* A shortened path for displaying where space is tight.
4151
*/
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 {
4367
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
5978
)
60-
}
6179

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 {
64115
setHost(hostname)
65116
setUsername("coder")
66117
port = 22
67118
authType = AuthType.OPEN_SSH
68119
}
69-
}
70120

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+
)
76135
)
77-
else DeployTargetInfo.NoDeploy(this.ideSource, this.toIdeInfo())
78136
}
79137

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()
85198
}
86199

87200
/**
88201
* Convert parameters into a recent workspace connection (for storage).
89202
*/
90203
fun toRecentWorkspaceConnection(): RecentWorkspaceConnection {
91204
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,
102214
)
103215
}
104216

105217
companion object {
218+
val logger = Logger.getInstance(WorkspaceProjectIDE::class.java.simpleName)
219+
106220
/**
107221
* Create from unvalidated user inputs.
108222
*/
@@ -111,38 +225,36 @@ class WorkspaceProjectIDE(
111225
name: String?,
112226
hostname: String?,
113227
projectPath: String?,
228+
deploymentURL: String?,
114229
lastOpened: String?,
115230
ideProductCode: String?,
116231
ideBuildNumber: String?,
117232
downloadSource: String?,
118233
idePathOnHost: String?,
119-
webTerminalLink: String?,
120-
configDirectory: String?,
121234
): 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")
125241
} else if (projectPath.isNullOrBlank()) {
126-
throw Error("project path is missing")
242+
throw Exception("Project path is missing")
127243
} else if (ideProductCode.isNullOrBlank()) {
128-
throw Error("ide product code is missing")
244+
throw Exception("IDE product code is missing")
129245
} 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")
133247
}
134248

135249
return WorkspaceProjectIDE(
136250
name = name,
137251
hostname = hostname,
138252
projectPath = projectPath,
139-
ideProductCode = IntelliJPlatformProduct.fromProductCode(ideProductCode) ?: throw Error("invalid product code"),
253+
ideProductCode = IntelliJPlatformProduct.fromProductCode(ideProductCode) ?: throw Exception("invalid product code"),
140254
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,
146258
lastOpened = lastOpened,
147259
)
148260
}
@@ -160,10 +272,9 @@ fun RecentWorkspaceConnection.toWorkspaceProjectIDE(): WorkspaceProjectIDE {
160272
projectPath = projectPath,
161273
ideProductCode = ideProductCode,
162274
ideBuildNumber = ideBuildNumber,
163-
webTerminalLink = webTerminalLink,
164-
configDirectory = configDirectory,
165275
idePathOnHost = idePathOnHost,
166276
downloadSource = downloadSource,
277+
deploymentURL = deploymentURL,
167278
lastOpened = lastOpened,
168279
)
169280
}
@@ -176,26 +287,25 @@ fun IdeWithStatus.withWorkspaceProject(
176287
name: String,
177288
hostname: String,
178289
projectPath: String,
179-
webTerminalLink: String,
180-
configDirectory: String,
290+
deploymentURL: String,
181291
): 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-
}
188292
return WorkspaceProjectIDE(
189293
name = name,
190294
hostname = hostname,
191295
projectPath = projectPath,
192296
ideProductCode = this.product,
193297
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,
199301
lastOpened = null,
200302
)
201303
}
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+
}

‎src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ open class CoderSettingsState(
6161
open var disableAutostart: Boolean = getOS() == OS.MAC,
6262
// Extra SSH config options.
6363
open var sshConfigOptions: String = "",
64+
// An external command that is ran in the directory of the IDE before
65+
// connecting to it.
66+
open var setupCommand: String = "",
6467
)
6568

6669
/**
@@ -123,6 +126,12 @@ open class CoderSettings(
123126
val sshConfigOptions: String
124127
get() = state.sshConfigOptions.ifBlank { env.get(CODER_SSH_CONFIG_OPTIONS) }
125128

129+
/**
130+
* A command to run extra IDE setup.
131+
*/
132+
val setupCommand: String
133+
get() = state.setupCommand
134+
126135
/**
127136
* Where the specified deployment should put its data.
128137
*/

‎src/main/resources/messages/CoderGatewayBundle.properties

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,8 @@ gateway.connector.settings.ssh-config-options.comment=Extra SSH config options \
122122
to use when connecting to a workspace. This text will be appended as-is to \
123123
the SSH configuration block for each workspace. If left blank the \
124124
environment variable {0} will be used, if set.
125+
gateway.connector.settings.setup-command.title=Setup command:
126+
gateway.connector.settings.setup-command.comment=An external command that \
127+
will be executed on the remote in the directory of the IDE before \
128+
connecting to it.
125129

‎src/test/kotlin/com/coder/gateway/settings/CoderSettingsTest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ internal class CoderSettingsTest {
198198
tlsCAPath = "tls ca path",
199199
tlsAlternateHostname = "tls alt hostname",
200200
disableAutostart = true,
201+
setupCommand = "test setup",
201202
)
202203
)
203204

@@ -209,5 +210,6 @@ internal class CoderSettingsTest {
209210
assertEquals("tls ca path", settings.tls.caPath)
210211
assertEquals("tls alt hostname", settings.tls.altHostname)
211212
assertEquals(true, settings.disableAutostart)
213+
assertEquals("test setup", settings.setupCommand)
212214
}
213215
}

0 commit comments

Comments
 (0)
Please sign in to comment.