Skip to content

Commit 3b6ce48

Browse files
authored
Allow customizing CLI locations (#225)
* Allow passing in binary source Also change destinationDir to allow a null to make it easier to pass in a setting that could be unset. * Add settings page for CLI locations * Remove unused message * Propagate non-zero exit code as exceptions Otherwise they will not be visible to the user. This is particularly important as it will probably be easy for a user to make a typo when overriding the binary URL and hit something that gives a 200 but is not actually the binary. * Load workspaces after downloading CLI Since you cannot interact with the list without a CLI (and there is now a higher chance something can go wrong since the user can customize the source and destination) we should avoid showing them until the CLI is good to go. * Check that CLI downloads the first time Getting a test failure on macOS and just want to make sure it does download the first time. * Simplify setting execute bit * Catch trying to create a directory at/under non-directory * Make CLI destination dir required
1 parent ca68ef3 commit 3b6ce48

File tree

10 files changed

+354
-55
lines changed

10 files changed

+354
-55
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.coder.gateway
2+
3+
import com.coder.gateway.sdk.CoderCLIManager
4+
import com.coder.gateway.sdk.canCreateDirectory
5+
import com.coder.gateway.services.CoderSettingsState
6+
import com.intellij.openapi.components.service
7+
import com.intellij.openapi.options.BoundConfigurable
8+
import com.intellij.openapi.ui.DialogPanel
9+
import com.intellij.openapi.ui.ValidationInfo
10+
import com.intellij.ui.components.JBTextField
11+
import com.intellij.ui.dsl.builder.AlignX
12+
import com.intellij.ui.dsl.builder.bindText
13+
import com.intellij.ui.dsl.builder.panel
14+
import com.intellij.ui.layout.ValidationInfoBuilder
15+
import java.net.URL
16+
import java.nio.file.Path
17+
18+
class CoderSettingsConfigurable : BoundConfigurable("Coder") {
19+
override fun createPanel(): DialogPanel {
20+
val state: CoderSettingsState = service()
21+
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+
}
32+
row(CoderGatewayBundle.message("gateway.connector.settings.binary-destination.title")) {
33+
textField().resizableColumn().align(AlignX.FILL)
34+
.bindText(state::binaryDestination)
35+
.validationOnApply(validateBinaryDestination())
36+
.validationOnInput(validateBinaryDestination())
37+
.comment(
38+
CoderGatewayBundle.message(
39+
"gateway.connector.settings.binary-destination.comment",
40+
CoderCLIManager.getDataDir(),
41+
)
42+
)
43+
}
44+
}
45+
}
46+
47+
private fun validateBinaryDestination(): ValidationInfoBuilder.(JBTextField) -> ValidationInfo? = {
48+
if (it.text.isNotBlank() && !Path.of(it.text).canCreateDirectory()) {
49+
error("Cannot create this directory")
50+
} else {
51+
null
52+
}
53+
}
54+
}

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

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import java.nio.file.Files
1414
import java.nio.file.Path
1515
import java.nio.file.Paths
1616
import java.nio.file.StandardCopyOption
17-
import java.nio.file.attribute.PosixFilePermissions
1817
import java.security.DigestInputStream
1918
import java.security.MessageDigest
2019
import java.util.zip.GZIPInputStream
@@ -26,23 +25,30 @@ import javax.xml.bind.annotation.adapters.HexBinaryAdapter
2625
*/
2726
class CoderCLIManager @JvmOverloads constructor(
2827
private val deploymentURL: URL,
29-
destinationDir: Path = getDataDir(),
28+
destinationDir: Path,
29+
remoteBinaryURLOverride: String? = null,
3030
private val sshConfigPath: Path = Path.of(System.getProperty("user.home")).resolve(".ssh/config"),
3131
) {
32-
private var remoteBinaryUrl: URL
32+
var remoteBinaryURL: URL
3333
var localBinaryPath: Path
3434
private var coderConfigPath: Path
3535

3636
init {
3737
val binaryName = getCoderCLIForOS(getOS(), getArch())
38-
remoteBinaryUrl = URL(
38+
remoteBinaryURL = URL(
3939
deploymentURL.protocol,
4040
deploymentURL.host,
4141
deploymentURL.port,
4242
"/bin/$binaryName"
4343
)
44-
// Convert IDN to ASCII in case the file system cannot support the
45-
// necessary character set.
44+
if (!remoteBinaryURLOverride.isNullOrBlank()) {
45+
logger.info("Using remote binary override $remoteBinaryURLOverride")
46+
remoteBinaryURL = try {
47+
remoteBinaryURLOverride.toURL()
48+
} catch (e: Exception) {
49+
remoteBinaryURL.withPath(remoteBinaryURLOverride)
50+
}
51+
}
4652
val host = getSafeHost(deploymentURL)
4753
val subdir = if (deploymentURL.port > 0) "${host}-${deploymentURL.port}" else host
4854
localBinaryPath = destinationDir.resolve(subdir).resolve(binaryName).toAbsolutePath()
@@ -86,7 +92,7 @@ class CoderCLIManager @JvmOverloads constructor(
8692
*/
8793
fun downloadCLI(): Boolean {
8894
val etag = getBinaryETag()
89-
val conn = remoteBinaryUrl.openConnection() as HttpURLConnection
95+
val conn = remoteBinaryURL.openConnection() as HttpURLConnection
9096
if (etag != null) {
9197
logger.info("Found existing binary at $localBinaryPath; calculated hash as $etag")
9298
conn.setRequestProperty("If-None-Match", "\"$etag\"")
@@ -95,7 +101,7 @@ class CoderCLIManager @JvmOverloads constructor(
95101

96102
try {
97103
conn.connect()
98-
logger.info("GET ${conn.responseCode} $remoteBinaryUrl")
104+
logger.info("GET ${conn.responseCode} $remoteBinaryURL")
99105
when (conn.responseCode) {
100106
HttpURLConnection.HTTP_OK -> {
101107
logger.info("Downloading binary to $localBinaryPath")
@@ -108,10 +114,7 @@ class CoderCLIManager @JvmOverloads constructor(
108114
)
109115
}
110116
if (getOS() != OS.WINDOWS) {
111-
Files.setPosixFilePermissions(
112-
localBinaryPath,
113-
PosixFilePermissions.fromString("rwxr-x---")
114-
)
117+
localBinaryPath.toFile().setExecutable(true)
115118
}
116119
return true
117120
}
@@ -124,7 +127,7 @@ class CoderCLIManager @JvmOverloads constructor(
124127
} finally {
125128
conn.disconnect()
126129
}
127-
throw ResponseException("Unexpected response from $remoteBinaryUrl", conn.responseCode)
130+
throw ResponseException("Unexpected response from $remoteBinaryURL", conn.responseCode)
128131
}
129132

130133
/**
@@ -283,6 +286,7 @@ class CoderCLIManager @JvmOverloads constructor(
283286
private fun exec(vararg args: String): String {
284287
val stdout = ProcessExecutor()
285288
.command(localBinaryPath.toString(), *args)
289+
.exitValues(0)
286290
.readOutput(true)
287291
.execute()
288292
.outputUTF8()
@@ -356,6 +360,10 @@ class CoderCLIManager @JvmOverloads constructor(
356360
}
357361
}
358362

363+
/**
364+
* Convert IDN to ASCII in case the file system cannot support the
365+
* necessary character set.
366+
*/
359367
private fun getSafeHost(url: URL): String {
360368
return IDN.toASCII(url.host, IDN.ALLOW_UNASSIGNED)
361369
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.coder.gateway.sdk
2+
3+
import java.nio.file.Files
4+
import java.nio.file.Path
5+
6+
/**
7+
* Return true if a directory can be created at the specified path or if one
8+
* already exists and we can write into it.
9+
*
10+
* Unlike File.canWrite() or Files.isWritable() the directory does not need to
11+
* exist; it only needs a writable parent and the target needs to be
12+
* non-existent or a directory (not a regular file or nested under one).
13+
*/
14+
fun Path.canCreateDirectory(): Boolean {
15+
var current: Path? = this.toAbsolutePath()
16+
while (current != null && !Files.exists(current)) {
17+
current = current.parent
18+
}
19+
// On Windows File.canWrite() only checks read-only while Files.isWritable()
20+
// also checks permissions so use the latter. Both check read-only only on
21+
// files, not directories; on Windows you are allowed to create files inside
22+
// read-only directories.
23+
return current != null && Files.isWritable(current) && Files.isDirectory(current)
24+
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,8 @@ fun String.toURL(): URL {
88
}
99

1010
fun URL.withPath(path: String): URL {
11-
return URL(this.protocol, this.host, this.port, path)
11+
return URL(
12+
this.protocol, this.host, this.port,
13+
if (path.startsWith("/")) path else "/$path"
14+
)
1215
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.coder.gateway.services
2+
3+
import com.intellij.openapi.components.PersistentStateComponent
4+
import com.intellij.openapi.components.RoamingType
5+
import com.intellij.openapi.components.Service
6+
import com.intellij.openapi.components.State
7+
import com.intellij.openapi.components.Storage
8+
import com.intellij.util.xmlb.XmlSerializerUtil
9+
10+
@Service(Service.Level.APP)
11+
@State(
12+
name = "CoderSettingsState",
13+
storages = [Storage("coder-settings.xml", roamingType = RoamingType.DISABLED, exportable = true)]
14+
)
15+
class CoderSettingsState : PersistentStateComponent<CoderSettingsState> {
16+
var binarySource: String = ""
17+
var binaryDestination: String = ""
18+
override fun getState(): CoderSettingsState {
19+
return this
20+
}
21+
22+
override fun loadState(state: CoderSettingsState) {
23+
XmlSerializerUtil.copyBean(state, this)
24+
}
25+
}

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import com.coder.gateway.sdk.ex.WorkspaceResponseException
2424
import com.coder.gateway.sdk.toURL
2525
import com.coder.gateway.sdk.v2.models.Workspace
2626
import com.coder.gateway.sdk.withPath
27+
import com.coder.gateway.services.CoderSettingsState
2728
import com.intellij.ide.ActivityTracker
2829
import com.intellij.ide.BrowserUtil
2930
import com.intellij.ide.IdeBundle
@@ -77,6 +78,7 @@ import java.awt.font.TextAttribute
7778
import java.awt.font.TextAttribute.UNDERLINE_ON
7879
import java.net.SocketTimeoutException
7980
import java.net.URL
81+
import java.nio.file.Path
8082
import javax.swing.Icon
8183
import javax.swing.JCheckBox
8284
import javax.swing.JTable
@@ -97,6 +99,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
9799
private var localWizardModel = CoderWorkspacesWizardModel()
98100
private val coderClient: CoderRestClientService = service()
99101
private val iconDownloader: TemplateIconDownloader = service()
102+
private val settings: CoderSettingsState = service()
100103

101104
private val appPropertiesService: PropertiesComponent = service()
102105

@@ -461,16 +464,21 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
461464
appPropertiesService.setValue(CODER_URL_KEY, deploymentURL.toString())
462465
appPropertiesService.setValue(SESSION_TOKEN, token)
463466

464-
this.indicator.text = "Retrieving workspaces..."
465-
loadWorkspaces()
466-
467467
this.indicator.text = "Downloading Coder CLI..."
468-
val cliManager = CoderCLIManager(deploymentURL)
468+
val cliManager = CoderCLIManager(
469+
deploymentURL,
470+
if (settings.binaryDestination.isNotBlank()) Path.of(settings.binaryDestination)
471+
else CoderCLIManager.getDataDir(),
472+
settings.binarySource,
473+
)
469474
cliManager.downloadCLI()
470475

471476
this.indicator.text = "Authenticating Coder CLI..."
472477
cliManager.login(token)
473478

479+
this.indicator.text = "Retrieving workspaces..."
480+
loadWorkspaces()
481+
474482
updateWorkspaceActions()
475483
triggerWorkspacePolling(false)
476484
} catch (e: AuthenticationResponseException) {
@@ -713,7 +721,12 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
713721
poller?.cancel()
714722

715723
logger.info("Configuring Coder CLI...")
716-
val cliManager = CoderCLIManager(wizardModel.coderURL.toURL())
724+
val cliManager = CoderCLIManager(
725+
wizardModel.coderURL.toURL(),
726+
if (settings.binaryDestination.isNotBlank()) Path.of(settings.binaryDestination)
727+
else CoderCLIManager.getDataDir(),
728+
settings.binarySource,
729+
)
717730
cliManager.configSsh(listTableModelOfWorkspaces.items)
718731

719732
logger.info("Opening IDE and Project Location window for ${workspace.name}")

src/main/resources/META-INF/plugin.xml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
<depends optional="true">com.jetbrains.gateway</depends>
1616

1717
<extensions defaultExtensionNs="com.intellij">
18-
<applicationService serviceImplementation="com.coder.gateway.sdk.CoderRestClientService"></applicationService>
19-
<applicationService serviceImplementation="com.coder.gateway.sdk.TemplateIconDownloader"></applicationService>
20-
<applicationService serviceImplementation="com.coder.gateway.services.CoderRecentWorkspaceConnectionsService"></applicationService>
21-
<webHelpProvider implementation="com.coder.gateway.help.CoderWebHelp"></webHelpProvider>
18+
<applicationService serviceImplementation="com.coder.gateway.sdk.CoderRestClientService"/>
19+
<applicationService serviceImplementation="com.coder.gateway.sdk.TemplateIconDownloader"/>
20+
<applicationService serviceImplementation="com.coder.gateway.services.CoderRecentWorkspaceConnectionsService"/>
21+
<applicationService serviceImplementation="com.coder.gateway.services.CoderSettingsState"/>
22+
<applicationConfigurable parentId="tools" instance="com.coder.gateway.CoderSettingsConfigurable"/>
23+
<webHelpProvider implementation="com.coder.gateway.help.CoderWebHelp"/>
2224
</extensions>
2325
<extensions defaultExtensionNs="com.jetbrains">
2426
<gatewayConnector implementation="com.coder.gateway.CoderGatewayMainView"/>
2527
<gatewayConnectionProvider implementation="com.coder.gateway.CoderGatewayConnectionProvider"/>
2628
</extensions>
27-
</idea-plugin>
29+
</idea-plugin>

src/main/resources/messages/CoderGatewayBundle.properties

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ gateway.connector.view.coder.workspaces.header.text=Coder Workspaces
1111
gateway.connector.view.coder.workspaces.comment=Self-hosted developer workspaces in the cloud or on-premises. Coder empowers developers with secure, consistent, and fast developer workspaces.
1212
gateway.connector.view.coder.workspaces.connect.text=Connect
1313
gateway.connector.view.coder.workspaces.cli.downloader.dialog.title=Authenticate and setup Coder
14-
gateway.connector.view.coder.workspaces.cli.configssh.dialog.title=Coder Config SSH
1514
gateway.connector.view.coder.workspaces.next.text=Select IDE and Project
1615
gateway.connector.view.coder.workspaces.dashboard.text=Open Dashboard
1716
gateway.connector.view.coder.workspaces.start.text=Start Workspace
@@ -34,3 +33,15 @@ gateway.connector.recentconnections.new.wizard.button.tooltip=Open a new Coder W
3433
gateway.connector.recentconnections.remove.button.tooltip=Remove from Recent Connections
3534
gateway.connector.recentconnections.terminal.button.tooltip=Open SSH Web Terminal
3635
gateway.connector.coder.connection.provider.title=Connecting to Coder workspace...
36+
gateway.connector.settings.binary-source.title=CLI source:
37+
gateway.connector.settings.binary-source.comment=Used to download the Coder \
38+
CLI which is necessary to make SSH connections. The If-None-Matched header \
39+
will be set to the SHA1 of the CLI and can be used for caching. Absolute \
40+
URLs will be used as-is; otherwise this value will be resolved against the \
41+
deployment domain. \
42+
Defaults to {0}.
43+
gateway.connector.settings.binary-destination.title=Data directory:
44+
gateway.connector.settings.binary-destination.comment=Directories are created \
45+
here that store the CLI and credentials for each domain to which the plugin \
46+
connects. \
47+
Defaults to {0}.

0 commit comments

Comments
 (0)