Skip to content

Commit f3663a4

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 f8159f1 commit f3663a4

File tree

7 files changed

+144
-70
lines changed

7 files changed

+144
-70
lines changed

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

Lines changed: 30 additions & 12 deletions
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,16 +21,6 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") {
1921
override fun createPanel(): DialogPanel {
2022
val state: CoderSettingsState = service()
2123
return panel {
22-
row(CoderGatewayBundle.message("gateway.connector.settings.binary-source.title")) {
23-
textField().resizableColumn().align(AlignX.FILL)
24-
.bindText(state::binarySource)
25-
.comment(
26-
CoderGatewayBundle.message(
27-
"gateway.connector.settings.binary-source.comment",
28-
CoderCLIManager(URL("http://localhost"), CoderCLIManager.getDataDir()).remoteBinaryURL.path,
29-
)
30-
)
31-
}
3224
row(CoderGatewayBundle.message("gateway.connector.settings.data-directory.title")) {
3325
textField().resizableColumn().align(AlignX.FILL)
3426
.bindText(state::dataDirectory)
@@ -40,14 +32,40 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") {
4032
CoderCLIManager.getDataDir(),
4133
)
4234
)
43-
}
35+
}.layout(RowLayout.PARENT_GRID)
36+
row(CoderGatewayBundle.message("gateway.connector.settings.binary-source.title")) {
37+
textField().resizableColumn().align(AlignX.FILL)
38+
.bindText(state::binarySource)
39+
.comment(
40+
CoderGatewayBundle.message(
41+
"gateway.connector.settings.binary-source.comment",
42+
CoderCLIManager(URL("http://localhost"), CoderCLIManager.getDataDir()).remoteBinaryURL.path,
43+
)
44+
)
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)
4454
// The binary directory is not validated because it could be a
4555
// read-only path that is pre-downloaded by admins.
4656
row(CoderGatewayBundle.message("gateway.connector.settings.binary-destination.title")) {
4757
textField().resizableColumn().align(AlignX.FILL)
4858
.bindText(state::binaryDirectory)
4959
.comment(CoderGatewayBundle.message("gateway.connector.settings.binary-destination.comment"))
50-
}
60+
}.layout(RowLayout.PARENT_GRID)
61+
row {
62+
cell() // For alignment.
63+
checkBox(CoderGatewayBundle.message("gateway.connector.settings.enable-binary-data-fallback.title"))
64+
.bindSelected(state::enableCLIDataFallback)
65+
.comment(
66+
CoderGatewayBundle.message("gateway.connector.settings.enable-binary-data-fallback.comment")
67+
)
68+
}.layout(RowLayout.PARENT_GRID)
5169
}
5270
}
5371

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

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.coder.gateway.sdk
33
import com.coder.gateway.models.WorkspaceAgentModel
44
import com.coder.gateway.views.steps.CoderWorkspacesStepView
55
import com.google.gson.Gson
6+
import com.google.gson.JsonSyntaxException
67
import com.intellij.openapi.diagnostic.Logger
78
import org.zeroturnaround.exec.ProcessExecutor
89
import java.io.BufferedInputStream
@@ -294,26 +295,47 @@ class CoderCLIManager @JvmOverloads constructor(
294295
val raw = exec("version", "--output", "json")
295296
val json = Gson().fromJson(raw, Version::class.java)
296297
if (json?.version == null) {
297-
throw InvalidVersionException("No version found in output")
298+
throw MissingVersionException("No version found in output")
298299
}
299300
return CoderSemVer.parse(json.version)
300301
}
301302

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

319341
private fun exec(vararg args: String): String {
@@ -419,5 +441,5 @@ class Environment(private val env: Map<String, String> = emptyMap()) {
419441
}
420442

421443
class ResponseException(message: String, val code: Int) : Exception(message)
422-
423444
class SSHConfigFormatException(message: String) : Exception(message)
445+
class MissingVersionException(message: String) : Exception(message)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ class CoderSettingsState : PersistentStateComponent<CoderSettingsState> {
1616
var binarySource: String = ""
1717
var binaryDirectory: String = ""
1818
var dataDirectory: String = ""
19+
var enableDownloads: Boolean = true
20+
var enableCLIDataFallback: Boolean = false
1921
override fun getState(): CoderSettingsState {
2022
return this
2123
}

src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt

Lines changed: 54 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import com.intellij.openapi.application.ApplicationManager
3333
import com.intellij.openapi.application.ModalityState
3434
import com.intellij.openapi.components.service
3535
import com.intellij.openapi.diagnostic.Logger
36+
import com.intellij.openapi.progress.ProgressIndicator
3637
import com.intellij.openapi.rd.util.launchUnderBackgroundProgress
3738
import com.intellij.openapi.ui.panel.ComponentPanelBuilder
3839
import com.intellij.openapi.ui.setEmptyState
@@ -458,44 +459,14 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
458459
canBeCancelled = false,
459460
isIndeterminate = true
460461
) {
461-
val dataDir =
462-
if (settings.dataDirectory.isBlank()) CoderCLIManager.getDataDir()
463-
else Path.of(settings.dataDirectory).toAbsolutePath()
464-
val cliDir =
465-
if (settings.binaryDirectory.isBlank()) null
466-
else Path.of(settings.binaryDirectory).toAbsolutePath()
467-
var cli = CoderCLIManager(deploymentURL, dataDir, cliDir, settings.binarySource)
462+
val cli = getCLIManager(deploymentURL, this.indicator)
468463
try {
469464
this.indicator.text = "Authenticating client..."
470465
authenticate(deploymentURL, token.first)
471466
// Remember these in order to default to them for future attempts.
472467
appPropertiesService.setValue(CODER_URL_KEY, deploymentURL.toString())
473468
appPropertiesService.setValue(SESSION_TOKEN, token.first)
474469

475-
// Short-circuit if we already have the expected version. This
476-
// lets us bypass the 304 which is slower and may not be
477-
// supported if the binary is downloaded from alternate sources.
478-
// For CLIs without the JSON output flag we will fall back to
479-
// the 304 method.
480-
if (!cli.matchesVersion(clientService.buildVersion)) {
481-
this.indicator.text = "Downloading Coder CLI..."
482-
try {
483-
cli.downloadCLI()
484-
} catch (e: java.nio.file.AccessDeniedException) {
485-
// Try the data directory instead.
486-
if (cliDir != null && !cliDir.startsWith(dataDir)) {
487-
val oldPath = cli.localBinaryPath
488-
cli = CoderCLIManager(deploymentURL, dataDir, null, settings.binarySource )
489-
logger.info("Cannot write to $oldPath, falling back to ${cli.localBinaryPath}")
490-
if (!cli.matchesVersion(clientService.buildVersion)) {
491-
cli.downloadCLI()
492-
}
493-
} else {
494-
throw e
495-
}
496-
}
497-
}
498-
499470
this.indicator.text = "Authenticating Coder CLI..."
500471
cli.login(token.first)
501472

@@ -548,6 +519,58 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
548519
}
549520
}
550521

522+
private fun getCLIManager(deploymentURL: URL, indicator: ProgressIndicator): CoderCLIManager {
523+
val dataDir =
524+
if (settings.dataDirectory.isBlank()) CoderCLIManager.getDataDir()
525+
else Path.of(settings.dataDirectory).toAbsolutePath()
526+
val cliDir =
527+
if (settings.binaryDirectory.isBlank()) null
528+
else Path.of(settings.binaryDirectory).toAbsolutePath()
529+
530+
val cli = CoderCLIManager(deploymentURL, dataDir, cliDir, settings.binarySource)
531+
532+
// Short-circuit if we already have the expected version. This
533+
// lets us bypass the 304 which is slower and may not be
534+
// supported if the binary is downloaded from alternate sources.
535+
// For CLIs without the JSON output flag we will fall back to
536+
// the 304 method.
537+
val cliMatches = cli.matchesVersion(clientService.buildVersion)
538+
if (cliMatches == true) {
539+
return cli
540+
}
541+
542+
// If downloads are enabled download the new version.
543+
if (settings.enableDownloads) {
544+
indicator.text = "Downloading Coder CLI..."
545+
try {
546+
cli.downloadCLI()
547+
return cli
548+
} catch (e: java.nio.file.AccessDeniedException) {
549+
// Might be able to fall back.
550+
if (cliDir != null && !cliDir.startsWith(dataDir) && settings.enableCLIDataFallback) {
551+
throw e
552+
}
553+
}
554+
}
555+
556+
// Try falling back to the data directory.
557+
val dataCLI = CoderCLIManager(deploymentURL, dataDir, null, settings.binarySource)
558+
if (dataCLI.matchesVersion(clientService.buildVersion) == true) {
559+
return dataCLI
560+
}
561+
562+
if (settings.enableDownloads) {
563+
indicator.text = "Downloading Coder CLI..."
564+
dataCLI.downloadCLI()
565+
return dataCLI
566+
}
567+
568+
// In the case where both directories do not have a matching binary
569+
// prefer whichever does have a binary, if any, and prefer the CLI
570+
// directory if they both have out-of-date versions.
571+
return if (cliMatches != null) cli else dataCLI
572+
}
573+
551574
/**
552575
* Open a dialog for providing the token. Show any existing token so the
553576
* user can validate it if a previous connection failed. If we are not
@@ -935,5 +958,4 @@ class WorkspacesTable : TableView<WorkspaceAgentModel>(WorkspacesTableModel()) {
935958
}
936959
return listTableModel.items.indexOfFirst { it.workspaceName == oldSelection.workspaceName }
937960
}
938-
939961
}

src/main/resources/messages/CoderGatewayBundle.properties

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,23 +48,28 @@ gateway.connector.coder.connecting=Connecting...
4848
gateway.connector.coder.connecting.retry=Connecting (attempt {0})...
4949
gateway.connector.coder.connection.failed=Failed to connect
5050
gateway.connector.coder.connecting.failed.retry=Failed to connect...retrying {0}
51+
gateway.connector.settings.data-directory.title=Data directory:
52+
gateway.connector.settings.data-directory.comment=Directories are created \
53+
here that store the credentials for each domain to which the plugin \
54+
connects. \
55+
Defaults to {0}.
5156
gateway.connector.settings.binary-source.title=CLI source:
5257
gateway.connector.settings.binary-source.comment=Used to download the Coder \
5358
CLI which is necessary to make SSH connections. The If-None-Matched header \
5459
will be set to the SHA1 of the CLI and can be used for caching. Absolute \
5560
URLs will be used as-is; otherwise this value will be resolved against the \
5661
deployment domain. \
5762
Defaults to {0}.
58-
gateway.connector.settings.data-directory.title=Data directory:
59-
gateway.connector.settings.data-directory.comment=Directories are created \
60-
here that store the credentials for each domain to which the plugin \
61-
connects. \
62-
Defaults to {0}.
63+
gateway.connector.settings.enable-downloads.title=Enable CLI downloads
64+
gateway.connector.settings.enable-downloads.comment=Checking this box will \
65+
allow the plugin to download the CLI if the current one is out of date or \
66+
does not exist.
6367
gateway.connector.settings.binary-destination.title=CLI directory:
6468
gateway.connector.settings.binary-destination.comment=Directories are created \
65-
here that store the CLI for each domain to which the plugin connects. If the \
66-
plugin does not have permissions to write here any existing CLI will still be \
67-
used but if the CLI needs to be downloaded or updated the data directory will \
68-
be used instead. \
69+
here that store the CLI for each domain to which the plugin connects. \
6970
Defaults to the data directory.
71+
gateway.connector.settings.enable-binary-data-fallback.title=Fall back to data directory
72+
gateway.connector.settings.enable-binary-data-fallback.comment=Checking this \
73+
box will allow the plugin to fall back to the data directory when the CLI \
74+
directory is not writable.
7075
gateway.connector.no-details="The error did not provide any further details"

src/test/groovy/CoderCLIManagerTest.groovy

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -487,9 +487,10 @@ class CoderCLIManagerTest extends Specification {
487487
where:
488488
contents | expected
489489
null | ProcessInitException
490-
"""echo '{"foo": true, "baz": 1}'""" | InvalidVersionException
490+
"""echo '{"foo": true, "baz": 1}'""" | MissingVersionException
491491
"""echo '{"version: '""" | JsonSyntaxException
492-
"exit 0" | InvalidVersionException
492+
"""echo '{"version": "invalid"}'""" | IllegalArgumentException
493+
"exit 0" | MissingVersionException
493494
"exit 1" | InvalidExitValueException
494495
}
495496

@@ -510,7 +511,7 @@ class CoderCLIManagerTest extends Specification {
510511

511512
where:
512513
contents | build | matches
513-
null | "v1.0.0" | false
514+
null | "v1.0.0" | null
514515
"""echo '{"version": "v1.0.0"}'""" | "v1.0.0" | true
515516
"""echo '{"version": "v1.0.0"}'""" | "v1.0.0-devel+b5b5b5b5" | true
516517
"""echo '{"version": "v1.0.0-devel+b5b5b5b5"}'""" | "v1.0.0-devel+b5b5b5b5" | true
@@ -524,8 +525,8 @@ class CoderCLIManagerTest extends Specification {
524525
"""echo '{"version": ""}'""" | "v1.0.0" | false
525526
"""echo '{"version": "v1.0.0"}'""" | "" | false
526527
"""echo '{"version'""" | "v1.0.0" | false
527-
"""exit 0""" | "v1.0.0" | false
528-
"""exit 1""" | "v1.0.0" | false
528+
"""exit 0""" | "v1.0.0" | null
529+
"""exit 1""" | "v1.0.0" | null
529530
}
530531

531532
def "separately configures cli path from data dir"() {

src/test/groovy/CoderWorkspacesStepViewTest.groovy

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,8 @@ class CoderWorkspacesStepViewTest extends Specification {
5151
DataGen.workspace("gone", "ws6") | 7 // Agent gone, workspace comes first.
5252
DataGen.workspace("agent3", "ws6") | 8 // Agent exact match.
5353
}
54+
55+
def "gets cli manager"() {
56+
57+
}
5458
}

0 commit comments

Comments
 (0)