Skip to content

Commit 3f780d4

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 3f780d4

File tree

7 files changed

+316
-78
lines changed

7 files changed

+316
-78
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

+102-15
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
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
@@ -26,7 +29,8 @@ import javax.xml.bind.annotation.adapters.HexBinaryAdapter
2629
*/
2730
class CoderCLIManager @JvmOverloads constructor(
2831
private val deploymentURL: URL,
29-
destinationDir: Path,
32+
dataDir: Path,
33+
cliDir: Path? = null,
3034
remoteBinaryURLOverride: String? = null,
3135
private val sshConfigPath: Path = Path.of(System.getProperty("user.home")).resolve(".ssh/config"),
3236
) {
@@ -52,8 +56,8 @@ class CoderCLIManager @JvmOverloads constructor(
5256
}
5357
val host = getSafeHost(deploymentURL)
5458
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()
59+
localBinaryPath = (cliDir ?: dataDir).resolve(subdir).resolve(binaryName).toAbsolutePath()
60+
coderConfigPath = dataDir.resolve(subdir).resolve("config").toAbsolutePath()
5761
}
5862

5963
/**
@@ -293,26 +297,47 @@ class CoderCLIManager @JvmOverloads constructor(
293297
val raw = exec("version", "--output", "json")
294298
val json = Gson().fromJson(raw, Version::class.java)
295299
if (json?.version == null) {
296-
throw InvalidVersionException("No version found in output")
300+
throw MissingVersionException("No version found in output")
297301
}
298302
return CoderSemVer.parse(json.version)
299303
}
300304

301305
/**
302306
* 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.
307+
* provided version, false if it does not match or either version is
308+
* invalid, or null if the CLI version could not be determined because the
309+
* binary could not be executed.
305310
*/
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
311+
fun matchesVersion(rawBuildVersion: String): Boolean? {
312+
val cliVersion = try {
313+
version()
312314
} catch (e: Exception) {
313-
logger.info("Unable to determine $localBinaryPath version: ${e.message}")
314-
false
315+
when (e) {
316+
is JsonSyntaxException,
317+
is IllegalArgumentException -> {
318+
logger.info("Got invalid version from $localBinaryPath: ${e.message}")
319+
return false
320+
}
321+
else -> {
322+
// An error here most likely means the CLI does not exist or
323+
// it executed successfully but output no version which
324+
// suggests it is not the right binary.
325+
logger.info("Unable to determine $localBinaryPath version: ${e.message}")
326+
return null
327+
}
328+
}
329+
}
330+
331+
val buildVersion = try {
332+
CoderSemVer.parse(rawBuildVersion)
333+
} catch (e: IllegalArgumentException) {
334+
logger.info("Got invalid build version: $rawBuildVersion")
335+
return false
315336
}
337+
338+
val matches = cliVersion == buildVersion
339+
logger.info("$localBinaryPath version $cliVersion matches $buildVersion: $matches")
340+
return matches
316341
}
317342

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

@@ -418,5 +505,5 @@ class Environment(private val env: Map<String, String> = emptyMap()) {
418505
}
419506

420507
class ResponseException(message: String, val code: Int) : Exception(message)
421-
422508
class SSHConfigFormatException(message: String) : Exception(message)
509+
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)