Skip to content

Commit 1f26933

Browse files
committed
Allow configuring CLI directory separately from data
This is so admins can download the CLI to some restricted location (like ProgramFiles).
1 parent abd7204 commit 1f26933

File tree

7 files changed

+324
-80
lines changed

7 files changed

+324
-80
lines changed

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

+35-10
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import com.intellij.openapi.ui.DialogPanel
99
import com.intellij.openapi.ui.ValidationInfo
1010
import com.intellij.ui.components.JBTextField
1111
import com.intellij.ui.dsl.builder.AlignX
12+
import com.intellij.ui.dsl.builder.RowLayout
13+
import com.intellij.ui.dsl.builder.bindSelected
1214
import com.intellij.ui.dsl.builder.bindText
1315
import com.intellij.ui.dsl.builder.panel
1416
import com.intellij.ui.layout.ValidationInfoBuilder
@@ -19,6 +21,18 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") {
1921
override fun createPanel(): DialogPanel {
2022
val state: CoderSettingsState = service()
2123
return panel {
24+
row(CoderGatewayBundle.message("gateway.connector.settings.data-directory.title")) {
25+
textField().resizableColumn().align(AlignX.FILL)
26+
.bindText(state::dataDirectory)
27+
.validationOnApply(validateDataDirectory())
28+
.validationOnInput(validateDataDirectory())
29+
.comment(
30+
CoderGatewayBundle.message(
31+
"gateway.connector.settings.data-directory.comment",
32+
CoderCLIManager.getDataDir(),
33+
)
34+
)
35+
}.layout(RowLayout.PARENT_GRID)
2236
row(CoderGatewayBundle.message("gateway.connector.settings.binary-source.title")) {
2337
textField().resizableColumn().align(AlignX.FILL)
2438
.bindText(state::binarySource)
@@ -28,23 +42,34 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") {
2842
CoderCLIManager(URL("http://localhost"), CoderCLIManager.getDataDir()).remoteBinaryURL.path,
2943
)
3044
)
31-
}
45+
}.layout(RowLayout.PARENT_GRID)
46+
row {
47+
cell() // For alignment.
48+
checkBox(CoderGatewayBundle.message("gateway.connector.settings.enable-downloads.title"))
49+
.bindSelected(state::enableDownloads)
50+
.comment(
51+
CoderGatewayBundle.message("gateway.connector.settings.enable-downloads.comment")
52+
)
53+
}.layout(RowLayout.PARENT_GRID)
54+
// The binary directory is not validated because it could be a
55+
// read-only path that is pre-downloaded by admins.
3256
row(CoderGatewayBundle.message("gateway.connector.settings.binary-destination.title")) {
3357
textField().resizableColumn().align(AlignX.FILL)
34-
.bindText(state::binaryDestination)
35-
.validationOnApply(validateBinaryDestination())
36-
.validationOnInput(validateBinaryDestination())
58+
.bindText(state::binaryDirectory)
59+
.comment(CoderGatewayBundle.message("gateway.connector.settings.binary-destination.comment"))
60+
}.layout(RowLayout.PARENT_GRID)
61+
row {
62+
cell() // For alignment.
63+
checkBox(CoderGatewayBundle.message("gateway.connector.settings.enable-binary-directory-fallback.title"))
64+
.bindSelected(state::enableBinaryDirectoryFallback)
3765
.comment(
38-
CoderGatewayBundle.message(
39-
"gateway.connector.settings.binary-destination.comment",
40-
CoderCLIManager.getDataDir(),
41-
)
66+
CoderGatewayBundle.message("gateway.connector.settings.enable-binary-directory-fallback.comment")
4267
)
43-
}
68+
}.layout(RowLayout.PARENT_GRID)
4469
}
4570
}
4671

47-
private fun validateBinaryDestination(): ValidationInfoBuilder.(JBTextField) -> ValidationInfo? = {
72+
private fun validateDataDirectory(): ValidationInfoBuilder.(JBTextField) -> ValidationInfo? = {
4873
if (it.text.isNotBlank() && !Path.of(it.text).canCreateDirectory()) {
4974
error("Cannot create this directory")
5075
} else {

src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt

+106-15
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
package com.coder.gateway.sdk
22

33
import com.coder.gateway.models.WorkspaceAgentModel
4+
import com.coder.gateway.services.CoderSettingsState
45
import com.coder.gateway.views.steps.CoderWorkspacesStepView
56
import com.google.gson.Gson
7+
import com.google.gson.JsonSyntaxException
68
import com.intellij.openapi.diagnostic.Logger
9+
import com.intellij.openapi.progress.ProgressIndicator
710
import org.zeroturnaround.exec.ProcessExecutor
811
import java.io.BufferedInputStream
912
import java.io.FileInputStream
1013
import java.io.FileNotFoundException
14+
import java.net.ConnectException
1115
import java.net.HttpURLConnection
1216
import java.net.IDN
1317
import java.net.URL
@@ -26,7 +30,8 @@ import javax.xml.bind.annotation.adapters.HexBinaryAdapter
2630
*/
2731
class CoderCLIManager @JvmOverloads constructor(
2832
private val deploymentURL: URL,
29-
destinationDir: Path,
33+
dataDir: Path,
34+
cliDir: Path? = null,
3035
remoteBinaryURLOverride: String? = null,
3136
private val sshConfigPath: Path = Path.of(System.getProperty("user.home")).resolve(".ssh/config"),
3237
) {
@@ -52,8 +57,8 @@ class CoderCLIManager @JvmOverloads constructor(
5257
}
5358
val host = getSafeHost(deploymentURL)
5459
val subdir = if (deploymentURL.port > 0) "${host}-${deploymentURL.port}" else host
55-
localBinaryPath = destinationDir.resolve(subdir).resolve(binaryName).toAbsolutePath()
56-
coderConfigPath = destinationDir.resolve(subdir).resolve("config").toAbsolutePath()
60+
localBinaryPath = (cliDir ?: dataDir).resolve(subdir).resolve(binaryName).toAbsolutePath()
61+
coderConfigPath = dataDir.resolve(subdir).resolve("config").toAbsolutePath()
5762
}
5863

5964
/**
@@ -125,6 +130,9 @@ class CoderCLIManager @JvmOverloads constructor(
125130
return false
126131
}
127132
}
133+
} catch (e: ConnectException) {
134+
// Add the URL so this is more easily debugged.
135+
throw ConnectException("${e.message} to $remoteBinaryURL")
128136
} finally {
129137
conn.disconnect()
130138
}
@@ -293,26 +301,47 @@ class CoderCLIManager @JvmOverloads constructor(
293301
val raw = exec("version", "--output", "json")
294302
val json = Gson().fromJson(raw, Version::class.java)
295303
if (json?.version == null) {
296-
throw InvalidVersionException("No version found in output")
304+
throw MissingVersionException("No version found in output")
297305
}
298306
return CoderSemVer.parse(json.version)
299307
}
300308

301309
/**
302310
* Returns true if the CLI has the same major/minor/patch version as the
303-
* provided version and false if it does not match or the CLI version could
304-
* not be determined or the provided version is invalid.
311+
* provided version, false if it does not match or either version is
312+
* invalid, or null if the CLI version could not be determined because the
313+
* binary could not be executed.
305314
*/
306-
fun matchesVersion(buildVersion: String): Boolean {
307-
return try {
308-
val cliVersion = version()
309-
val matches = cliVersion == CoderSemVer.parse(buildVersion)
310-
logger.info("$localBinaryPath version $cliVersion matches $buildVersion: $matches")
311-
matches
315+
fun matchesVersion(rawBuildVersion: String): Boolean? {
316+
val cliVersion = try {
317+
version()
312318
} catch (e: Exception) {
313-
logger.info("Unable to determine $localBinaryPath version: ${e.message}")
314-
false
319+
when (e) {
320+
is JsonSyntaxException,
321+
is IllegalArgumentException -> {
322+
logger.info("Got invalid version from $localBinaryPath: ${e.message}")
323+
return false
324+
}
325+
else -> {
326+
// An error here most likely means the CLI does not exist or
327+
// it executed successfully but output no version which
328+
// suggests it is not the right binary.
329+
logger.info("Unable to determine $localBinaryPath version: ${e.message}")
330+
return null
331+
}
332+
}
333+
}
334+
335+
val buildVersion = try {
336+
CoderSemVer.parse(rawBuildVersion)
337+
} catch (e: IllegalArgumentException) {
338+
logger.info("Got invalid build version: $rawBuildVersion")
339+
return false
315340
}
341+
342+
val matches = cliVersion == buildVersion
343+
logger.info("$localBinaryPath version $cliVersion matches $buildVersion: $matches")
344+
return matches
316345
}
317346

318347
private fun exec(vararg args: String): String {
@@ -404,6 +433,68 @@ class CoderCLIManager @JvmOverloads constructor(
404433
fun getHostName(url: URL, ws: WorkspaceAgentModel): String {
405434
return "coder-jetbrains--${ws.name}--${getSafeHost(url)}"
406435
}
436+
437+
/**
438+
* Do as much as possible to get a valid, up-to-date CLI.
439+
*/
440+
@JvmStatic
441+
@JvmOverloads
442+
fun ensureCLI(
443+
deploymentURL: URL,
444+
buildVersion: String,
445+
settings: CoderSettingsState,
446+
indicator: ProgressIndicator? = null,
447+
): CoderCLIManager {
448+
val dataDir =
449+
if (settings.dataDirectory.isBlank()) getDataDir()
450+
else Path.of(settings.dataDirectory).toAbsolutePath()
451+
val binDir =
452+
if (settings.binaryDirectory.isBlank()) null
453+
else Path.of(settings.binaryDirectory).toAbsolutePath()
454+
455+
val cli = CoderCLIManager(deploymentURL, dataDir, binDir, settings.binarySource)
456+
457+
// Short-circuit if we already have the expected version. This
458+
// lets us bypass the 304 which is slower and may not be
459+
// supported if the binary is downloaded from alternate sources.
460+
// For CLIs without the JSON output flag we will fall back to
461+
// the 304 method.
462+
val cliMatches = cli.matchesVersion(buildVersion)
463+
if (cliMatches == true) {
464+
return cli
465+
}
466+
467+
// If downloads are enabled download the new version.
468+
if (settings.enableDownloads) {
469+
indicator?.text = "Downloading Coder CLI..."
470+
try {
471+
cli.downloadCLI()
472+
return cli
473+
} catch (e: java.nio.file.AccessDeniedException) {
474+
// Might be able to fall back.
475+
if (binDir == null || binDir == dataDir || !settings.enableBinaryDirectoryFallback) {
476+
throw e
477+
}
478+
}
479+
}
480+
481+
// Try falling back to the data directory.
482+
val dataCLI = CoderCLIManager(deploymentURL, dataDir, null, settings.binarySource)
483+
val dataCLIMatches = dataCLI.matchesVersion(buildVersion)
484+
if (dataCLIMatches == true) {
485+
return dataCLI
486+
}
487+
488+
if (settings.enableDownloads) {
489+
indicator?.text = "Downloading Coder CLI..."
490+
dataCLI.downloadCLI()
491+
return dataCLI
492+
}
493+
494+
// Prefer the binary directory unless the data directory has a
495+
// working binary and the binary directory does not.
496+
return if (cliMatches == null && dataCLIMatches != null) dataCLI else cli
497+
}
407498
}
408499
}
409500

@@ -418,5 +509,5 @@ class Environment(private val env: Map<String, String> = emptyMap()) {
418509
}
419510

420511
class ResponseException(message: String, val code: Int) : Exception(message)
421-
422512
class SSHConfigFormatException(message: String) : Exception(message)
513+
class MissingVersionException(message: String) : Exception(message)

src/main/kotlin/com/coder/gateway/services/CoderSettingsState.kt

+4-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import com.intellij.util.xmlb.XmlSerializerUtil
1414
)
1515
class CoderSettingsState : PersistentStateComponent<CoderSettingsState> {
1616
var binarySource: String = ""
17-
var binaryDestination: String = ""
17+
var binaryDirectory: String = ""
18+
var dataDirectory: String = ""
19+
var enableDownloads: Boolean = true
20+
var enableBinaryDirectoryFallback: Boolean = false
1821
override fun getState(): CoderSettingsState {
1922
return this
2023
}

0 commit comments

Comments
 (0)