11package com.coder.gateway.sdk
22
33import com.coder.gateway.models.WorkspaceAgentModel
4+ import com.coder.gateway.services.CoderSettingsState
45import com.coder.gateway.views.steps.CoderWorkspacesStepView
56import com.google.gson.Gson
7+ import com.google.gson.JsonSyntaxException
68import com.intellij.openapi.diagnostic.Logger
9+ import com.intellij.openapi.progress.ProgressIndicator
710import org.zeroturnaround.exec.ProcessExecutor
811import java.io.BufferedInputStream
912import java.io.FileInputStream
1013import java.io.FileNotFoundException
14+ import java.net.ConnectException
1115import java.net.HttpURLConnection
1216import java.net.IDN
1317import java.net.URL
@@ -26,7 +30,8 @@ import javax.xml.bind.annotation.adapters.HexBinaryAdapter
2630 */
2731class 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
420511class ResponseException (message : String , val code : Int ) : Exception(message)
421-
422512class SSHConfigFormatException (message : String ) : Exception(message)
513+ class MissingVersionException (message : String ) : Exception(message)
0 commit comments