Skip to content

Commit aec6c8b

Browse files
committed
Allow configuring CLI directory separately from data
This is so admins can download the CLI to some restricted location (like ProgramFiles). This will break if they upload the wrong version and the plugin tries to download a new one since it will not have permissions to do so.
1 parent ebe4bed commit aec6c8b

File tree

6 files changed

+117
-48
lines changed

6 files changed

+117
-48
lines changed

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

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,22 +29,29 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") {
2929
)
3030
)
3131
}
32-
row(CoderGatewayBundle.message("gateway.connector.settings.binary-destination.title")) {
32+
row(CoderGatewayBundle.message("gateway.connector.settings.data-directory.title")) {
3333
textField().resizableColumn().align(AlignX.FILL)
34-
.bindText(state::binaryDestination)
35-
.validationOnApply(validateBinaryDestination())
36-
.validationOnInput(validateBinaryDestination())
34+
.bindText(state::dataDirectory)
35+
.validationOnApply(validateDataDirectory())
36+
.validationOnInput(validateDataDirectory())
3737
.comment(
3838
CoderGatewayBundle.message(
39-
"gateway.connector.settings.binary-destination.comment",
39+
"gateway.connector.settings.data-directory.comment",
4040
CoderCLIManager.getDataDir(),
4141
)
4242
)
4343
}
44+
// The binary destination is not validated because it could be a
45+
// read-only path that is pre-downloaded by admins.
46+
row(CoderGatewayBundle.message("gateway.connector.settings.binary-destination.title")) {
47+
textField().resizableColumn().align(AlignX.FILL)
48+
.bindText(state::binaryDirectory)
49+
.comment(CoderGatewayBundle.message("gateway.connector.settings.binary-destination.comment"))
50+
}
4451
}
4552
}
4653

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

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ import javax.xml.bind.annotation.adapters.HexBinaryAdapter
2626
*/
2727
class CoderCLIManager @JvmOverloads constructor(
2828
private val deploymentURL: URL,
29-
destinationDir: Path,
29+
dataDir: Path,
30+
cliDir: Path? = null,
3031
remoteBinaryURLOverride: String? = null,
3132
private val sshConfigPath: Path = Path.of(System.getProperty("user.home")).resolve(".ssh/config"),
3233
) {
@@ -52,8 +53,8 @@ class CoderCLIManager @JvmOverloads constructor(
5253
}
5354
val host = getSafeHost(deploymentURL)
5455
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()
56+
localBinaryPath = (cliDir ?: dataDir).resolve(subdir).resolve(binaryName).toAbsolutePath()
57+
coderConfigPath = dataDir.resolve(subdir).resolve("config").toAbsolutePath()
5758
}
5859

5960
/**

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ 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 = ""
1819
override fun getState(): CoderSettingsState {
1920
return this
2021
}

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

Lines changed: 56 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import kotlinx.coroutines.delay
6969
import kotlinx.coroutines.isActive
7070
import kotlinx.coroutines.launch
7171
import kotlinx.coroutines.withContext
72+
import org.zeroturnaround.exec.InvalidExitValueException
7273
import java.awt.Component
7374
import java.awt.Dimension
7475
import java.awt.event.MouseEvent
@@ -99,6 +100,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
99100
private val cs = CoroutineScope(Dispatchers.Main)
100101
private var localWizardModel = CoderWorkspacesWizardModel()
101102
private val clientService: CoderRestClientService = service()
103+
private var cliManager: CoderCLIManager? = null
102104
private val iconDownloader: TemplateIconDownloader = service()
103105
private val settings: CoderSettingsState = service()
104106

@@ -339,6 +341,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
339341
}
340342

341343
override fun onInit(wizardModel: CoderWorkspacesWizardModel) {
344+
cliManager = null
342345
tableOfWorkspaces.listTableModel.items = emptyList()
343346
if (localWizardModel.coderURL.isNotBlank() && localWizardModel.token != null) {
344347
triggerWorkspacePolling(true)
@@ -443,6 +446,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
443446
onAuthFailure: (() -> Unit)? = null,
444447
): Job {
445448
// Clear out old deployment details.
449+
cliManager = null
446450
poller?.cancel()
447451
tableOfWorkspaces.setEmptyState("Connecting to $deploymentURL...")
448452
tableOfWorkspaces.listTableModel.items = emptyList()
@@ -454,12 +458,13 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
454458
canBeCancelled = false,
455459
isIndeterminate = true
456460
) {
457-
val cliManager = CoderCLIManager(
458-
deploymentURL,
459-
if (settings.binaryDestination.isNotBlank()) Path.of(settings.binaryDestination)
460-
else CoderCLIManager.getDataDir(),
461-
settings.binarySource,
462-
)
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)
463468
try {
464469
this.indicator.text = "Authenticating client..."
465470
authenticate(deploymentURL, token.first)
@@ -472,23 +477,42 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
472477
// supported if the binary is downloaded from alternate sources.
473478
// For CLIs without the JSON output flag we will fall back to
474479
// the 304 method.
475-
if (!cliManager.matchesVersion(clientService.buildVersion)) {
480+
if (!cli.matchesVersion(clientService.buildVersion)) {
476481
this.indicator.text = "Downloading Coder CLI..."
477-
cliManager.downloadCLI()
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+
}
478497
}
479498

480499
this.indicator.text = "Authenticating Coder CLI..."
481-
cliManager.login(token.first)
500+
cli.login(token.first)
482501

483502
this.indicator.text = "Retrieving workspaces..."
484503
loadWorkspaces()
485504

486505
updateWorkspaceActions()
487506
triggerWorkspacePolling(false)
488507

508+
cliManager = cli
489509
tableOfWorkspaces.setEmptyState("Connected to $deploymentURL")
490510
} catch (e: Exception) {
491-
val errorSummary = e.message ?: "No reason was provided"
511+
val errorSummary = when (e) {
512+
is java.nio.file.AccessDeniedException -> "Access denied to ${e.message}"
513+
is InvalidExitValueException -> "CLI exited unexpectedly with ${e.exitValue}"
514+
else -> e.message ?: "No reason was provided"
515+
}
492516
var msg = CoderGatewayBundle.message(
493517
"gateway.connector.view.workspaces.connect.failed",
494518
deploymentURL,
@@ -513,7 +537,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
513537
is ResponseException, is ConnectException -> {
514538
msg = CoderGatewayBundle.message(
515539
"gateway.connector.view.workspaces.connect.download-failed",
516-
cliManager.remoteBinaryURL,
540+
cli.remoteBinaryURL,
517541
errorSummary,
518542
)
519543
}
@@ -700,29 +724,30 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
700724
token = localWizardModel.token
701725
}
702726

727+
// These being null would be a developer error.
703728
val workspace = tableOfWorkspaces.selectedObject
704-
if (workspace != null) {
705-
wizardModel.selectedWorkspace = workspace
706-
poller?.cancel()
707-
708-
logger.info("Configuring Coder CLI...")
709-
val cliManager = CoderCLIManager(
710-
wizardModel.coderURL.toURL(),
711-
if (settings.binaryDestination.isNotBlank()) Path.of(settings.binaryDestination)
712-
else CoderCLIManager.getDataDir(),
713-
settings.binarySource,
714-
)
715-
cliManager.configSsh(tableOfWorkspaces.items)
729+
val cli = cliManager
730+
if (workspace == null) {
731+
logger.error("No selected workspace")
732+
return false
733+
} else if (cli == null) {
734+
logger.error("No configured CLI")
735+
return false
736+
}
716737

717-
// The config directory can be used to pull the URL and token in
718-
// order to query this workspace's status in other flows, for
719-
// example from the recent connections screen.
720-
wizardModel.configDirectory = cliManager.coderConfigPath.toString()
738+
wizardModel.selectedWorkspace = workspace
739+
poller?.cancel()
721740

722-
logger.info("Opening IDE and Project Location window for ${workspace.name}")
723-
return true
724-
}
725-
return false
741+
logger.info("Configuring Coder CLI...")
742+
cli.configSsh(tableOfWorkspaces.items)
743+
744+
// The config directory can be used to pull the URL and token in
745+
// order to query this workspace's status in other flows, for
746+
// example from the recent connections screen.
747+
wizardModel.configDirectory = cli.coderConfigPath.toString()
748+
749+
logger.info("Opening IDE and Project Location window for ${workspace.name}")
750+
return true
726751
}
727752

728753
override fun dispose() {

src/main/resources/messages/CoderGatewayBundle.properties

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ gateway.connector.view.coder.workspaces.unsupported.coder.version=Coder version
2323
gateway.connector.view.workspaces.connect.unauthorized=Token was rejected by {0}; has your token expired?
2424
gateway.connector.view.workspaces.connect.timeout=Unable to connect to {0}; is it up?
2525
gateway.connector.view.workspaces.connect.download-failed=Failed to download Coder CLI from {0}: {1}
26-
gateway.connector.view.workspaces.connect.failed=Failed to configure connection to {0}: {1}
26+
gateway.connector.view.workspaces.connect.failed=Failed to connect to {0}: {1}
2727
gateway.connector.view.workspaces.token.comment=The last used token is shown above.
2828
gateway.connector.view.workspaces.token.rejected=This token was rejected.
2929
gateway.connector.view.workspaces.token.injected=This token was pulled from your CLI config.
@@ -55,9 +55,16 @@ gateway.connector.settings.binary-source.comment=Used to download the Coder \
5555
URLs will be used as-is; otherwise this value will be resolved against the \
5656
deployment domain. \
5757
Defaults to {0}.
58-
gateway.connector.settings.binary-destination.title=Data directory:
59-
gateway.connector.settings.binary-destination.comment=Directories are created \
60-
here that store the CLI and credentials for each domain to which the plugin \
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 \
6161
connects. \
6262
Defaults to {0}.
63+
gateway.connector.settings.binary-destination.title=CLI directory:
64+
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+
Defaults to the data directory.
6370
gateway.connector.no-details="The error did not provide any further details"

src/test/groovy/CoderCLIManagerTest.groovy

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import org.zeroturnaround.exec.InvalidExitValueException
88
import org.zeroturnaround.exec.ProcessInitException
99
import spock.lang.*
1010

11+
import java.nio.file.AccessDeniedException
1112
import java.nio.file.Files
1213
import java.nio.file.Path
1314
import java.nio.file.StandardCopyOption
@@ -118,6 +119,24 @@ class CoderCLIManagerTest extends Specification {
118119
srv.stop(0)
119120
}
120121

122+
def "fails to write"() {
123+
given:
124+
def (srv, url) = mockServer()
125+
def dir = tmpdir.resolve("cli-dir-fallback")
126+
def ccm = new CoderCLIManager(new URL(url), tmpdir, dir)
127+
Files.createDirectories(ccm.localBinaryPath.getParent())
128+
ccm.localBinaryPath.parent.toFile().setWritable(false)
129+
130+
when:
131+
ccm.downloadCLI()
132+
133+
then:
134+
thrown(AccessDeniedException)
135+
136+
cleanup:
137+
srv.stop(0)
138+
}
139+
121140
// This test uses a real deployment if possible to make sure we really
122141
// download a working CLI and that it runs on each platform.
123142
@Requires({ env["CODER_GATEWAY_TEST_DEPLOYMENT"] != "mock" })
@@ -242,7 +261,7 @@ class CoderCLIManagerTest extends Specification {
242261
def "overrides binary URL"() {
243262
given:
244263
def (srv, url) = mockServer()
245-
def ccm = new CoderCLIManager(new URL(url), tmpdir, override.replace("{{url}}", url))
264+
def ccm = new CoderCLIManager(new URL(url), tmpdir, null, override.replace("{{url}}", url))
246265

247266
when:
248267
def downloaded = ccm.downloadCLI()
@@ -362,7 +381,7 @@ class CoderCLIManagerTest extends Specification {
362381
def "configures an SSH file"() {
363382
given:
364383
def sshConfigPath = tmpdir.resolve(input + "_to_" + output + ".conf")
365-
def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir, null, sshConfigPath)
384+
def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir, null, null, sshConfigPath)
366385
if (input != null) {
367386
Files.createDirectories(sshConfigPath.getParent())
368387
def originalConf = Path.of("src/test/fixtures/inputs").resolve(input + ".conf").toFile().text
@@ -407,7 +426,7 @@ class CoderCLIManagerTest extends Specification {
407426
def "fails if config is malformed"() {
408427
given:
409428
def sshConfigPath = tmpdir.resolve("configured" + input + ".conf")
410-
def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir, null, sshConfigPath)
429+
def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir, null, null, sshConfigPath)
411430
Files.createDirectories(sshConfigPath.getParent())
412431
Files.copy(
413432
Path.of("src/test/fixtures/inputs").resolve(input + ".conf"),
@@ -508,4 +527,13 @@ class CoderCLIManagerTest extends Specification {
508527
"""exit 0""" | "v1.0.0" | false
509528
"""exit 1""" | "v1.0.0" | false
510529
}
530+
531+
def "separately configures cli path from data dir"() {
532+
given:
533+
def dir = tmpdir.resolve("cli-dir")
534+
def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir, dir)
535+
536+
expect:
537+
ccm.localBinaryPath.getParent() == dir.resolve("test.coder.invalid")
538+
}
511539
}

0 commit comments

Comments
 (0)