From cdc6fda231139d4523d902740a4cb4333a61e19a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Feb 2025 16:46:22 -0600 Subject: [PATCH 1/8] Changelog update - v2.18.1 (#536) Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5870adca..4be960fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## Unreleased +## 2.18.1 - 2025-02-14 + ### Changed - Update the `pluginUntilBuild` to latest EAP From 7d8ad4b9402ac7fbaba50a44ae2261444d7c29a7 Mon Sep 17 00:00:00 2001 From: Benjamin Peinhardt <61021968+bcpeinhardt@users.noreply.github.com> Date: Thu, 20 Feb 2025 09:15:44 -0600 Subject: [PATCH 2/8] prevent unintended early return from skipping writing wildcard configs (#539) prevent unintended early return from skipping writing wildcard configs --- CHANGELOG.md | 4 + .../gateway/CoderSettingsConfigurable.kt | 13 ++- .../com/coder/gateway/cli/CoderCLIManager.kt | 97 +++++++++---------- .../com/coder/gateway/icons/CoderIcons.kt | 83 ++++++++-------- .../gateway/models/WorkspaceAndAgentStatus.kt | 13 ++- .../sdk/convertors/InstantConverter.kt | 7 +- .../coder/gateway/settings/CoderSettings.kt | 2 +- src/test/fixtures/inputs/wildcard.conf | 17 ++++ .../coder/gateway/cli/CoderCLIManagerTest.kt | 18 +++- 9 files changed, 140 insertions(+), 114 deletions(-) create mode 100644 src/test/fixtures/inputs/wildcard.conf diff --git a/CHANGELOG.md b/CHANGELOG.md index 4be960fa..a14be9e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## Unreleased +### Fixed + +- Fix bug where wildcard configs would not be written under certain conditions. + ## 2.18.1 - 2025-02-14 ### Changed diff --git a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt index 8b51c704..18373983 100644 --- a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt +++ b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt @@ -167,12 +167,11 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") { } } - private fun validateDataDirectory(): ValidationInfoBuilder.(JBTextField) -> ValidationInfo? = - { - if (it.text.isNotBlank() && !Path.of(it.text).canCreateDirectory()) { - error("Cannot create this directory") - } else { - null - } + private fun validateDataDirectory(): ValidationInfoBuilder.(JBTextField) -> ValidationInfo? = { + if (it.text.isNotBlank() && !Path.of(it.text).canCreateDirectory()) { + error("Cannot create this directory") + } else { + null } + } } diff --git a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt index 43083c62..cc883a3b 100644 --- a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt @@ -257,7 +257,6 @@ class CoderCLIManager( val host = deploymentURL.safeHost() val startBlock = "# --- START CODER JETBRAINS $host" val endBlock = "# --- END CODER JETBRAINS $host" - val isRemoving = workspaceNames.isEmpty() val baseArgs = listOfNotNull( escape(localBinaryPath.toString()), @@ -308,35 +307,36 @@ class CoderCLIManager( Host ${getHostPrefix()}-bg--* ProxyCommand ${backgroundProxyArgs.joinToString(" ")} --ssh-host-prefix ${getHostPrefix()}-bg-- %h """.trimIndent() - .plus("\n" + sshOpts.prependIndent(" ")) - .plus(extraConfig), + .plus("\n" + sshOpts.prependIndent(" ")) + .plus(extraConfig), ).replace("\n", System.lineSeparator()) + System.lineSeparator() + endBlock - - } else { - workspaceNames.joinToString( - System.lineSeparator(), - startBlock + System.lineSeparator(), - System.lineSeparator() + endBlock, - transform = { - """ + } else if (workspaceNames.isEmpty()) { + "" + } else { + workspaceNames.joinToString( + System.lineSeparator(), + startBlock + System.lineSeparator(), + System.lineSeparator() + endBlock, + transform = { + """ Host ${getHostName(it.first, currentUser, it.second)} ProxyCommand ${proxyArgs.joinToString(" ")} ${getWorkspaceParts(it.first, it.second)} - """.trimIndent() - .plus("\n" + sshOpts.prependIndent(" ")) - .plus(extraConfig) - .plus("\n") - .plus( - """ + """.trimIndent() + .plus("\n" + sshOpts.prependIndent(" ")) + .plus(extraConfig) + .plus("\n") + .plus( + """ Host ${getBackgroundHostName(it.first, currentUser, it.second)} ProxyCommand ${backgroundProxyArgs.joinToString(" ")} ${getWorkspaceParts(it.first, it.second)} - """.trimIndent() - .plus("\n" + sshOpts.prependIndent(" ")) - .plus(extraConfig), - ).replace("\n", System.lineSeparator()) - }, - ) - } + """.trimIndent() + .plus("\n" + sshOpts.prependIndent(" ")) + .plus(extraConfig), + ).replace("\n", System.lineSeparator()) + }, + ) + } if (contents == null) { logger.info("No existing SSH config to modify") @@ -346,6 +346,8 @@ class CoderCLIManager( val start = "(\\s*)$startBlock".toRegex().find(contents) val end = "$endBlock(\\s*)".toRegex().find(contents) + val isRemoving = blockContent.isEmpty() + if (start == null && end == null && isRemoving) { logger.info("No workspaces and no existing config blocks to remove") return null @@ -477,15 +479,13 @@ class CoderCLIManager( * * Throws if the command execution fails. */ - fun startWorkspace(workspaceOwner: String, workspaceName: String): String { - return exec( - "--global-config", - coderConfigPath.toString(), - "start", - "--yes", - workspaceOwner+"/"+workspaceName, - ) - } + fun startWorkspace(workspaceOwner: String, workspaceName: String): String = exec( + "--global-config", + coderConfigPath.toString(), + "start", + "--yes", + workspaceOwner + "/" + workspaceName, + ) private fun exec(vararg args: String): String { val stdout = @@ -518,8 +518,7 @@ class CoderCLIManager( /* * This function returns the ssh-host-prefix used for Host entries. */ - fun getHostPrefix(): String = - "coder-jetbrains-${deploymentURL.safeHost()}" + fun getHostPrefix(): String = "coder-jetbrains-${deploymentURL.safeHost()}" /** * This function returns the ssh host name generated for connecting to the workspace. @@ -528,16 +527,15 @@ class CoderCLIManager( workspace: Workspace, currentUser: User, agent: WorkspaceAgent, - ): String = - if (features.wildcardSSH) { - "${getHostPrefix()}--${workspace.ownerName}--${workspace.name}.${agent.name}" + ): String = if (features.wildcardSSH) { + "${getHostPrefix()}--${workspace.ownerName}--${workspace.name}.${agent.name}" + } else { + // For a user's own workspace, we use the old syntax without a username for backwards compatibility, + // since the user might have recent connections that still use the old syntax. + if (currentUser.username == workspace.ownerName) { + "coder-jetbrains--${workspace.name}.${agent.name}--${deploymentURL.safeHost()}" } else { - // For a user's own workspace, we use the old syntax without a username for backwards compatibility, - // since the user might have recent connections that still use the old syntax. - if (currentUser.username == workspace.ownerName) { - "coder-jetbrains--${workspace.name}.${agent.name}--${deploymentURL.safeHost()}" - } else { - "coder-jetbrains--${workspace.ownerName}--${workspace.name}.${agent.name}--${deploymentURL.safeHost()}" + "coder-jetbrains--${workspace.ownerName}--${workspace.name}.${agent.name}--${deploymentURL.safeHost()}" } } @@ -545,12 +543,11 @@ class CoderCLIManager( workspace: Workspace, currentUser: User, agent: WorkspaceAgent, - ): String = - if (features.wildcardSSH) { - "${getHostPrefix()}-bg--${workspace.ownerName}--${workspace.name}.${agent.name}" - } else { - getHostName(workspace, currentUser, agent) + "--bg" - } + ): String = if (features.wildcardSSH) { + "${getHostPrefix()}-bg--${workspace.ownerName}--${workspace.name}.${agent.name}" + } else { + getHostName(workspace, currentUser, agent) + "--bg" + } companion object { val logger = Logger.getInstance(CoderCLIManager::class.java.simpleName) diff --git a/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt b/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt index 9026af52..3011e633 100644 --- a/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt +++ b/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt @@ -63,48 +63,47 @@ object CoderIcons { private val Y = IconLoader.getIcon("symbols/y.svg", javaClass) private val Z = IconLoader.getIcon("symbols/z.svg", javaClass) - fun fromChar(c: Char) = - when (c) { - '0' -> ZERO - '1' -> ONE - '2' -> TWO - '3' -> THREE - '4' -> FOUR - '5' -> FIVE - '6' -> SIX - '7' -> SEVEN - '8' -> EIGHT - '9' -> NINE - - 'a' -> A - 'b' -> B - 'c' -> C - 'd' -> D - 'e' -> E - 'f' -> F - 'g' -> G - 'h' -> H - 'i' -> I - 'j' -> J - 'k' -> K - 'l' -> L - 'm' -> M - 'n' -> N - 'o' -> O - 'p' -> P - 'q' -> Q - 'r' -> R - 's' -> S - 't' -> T - 'u' -> U - 'v' -> V - 'w' -> W - 'x' -> X - 'y' -> Y - 'z' -> Z - - else -> UNKNOWN - } + fun fromChar(c: Char) = when (c) { + '0' -> ZERO + '1' -> ONE + '2' -> TWO + '3' -> THREE + '4' -> FOUR + '5' -> FIVE + '6' -> SIX + '7' -> SEVEN + '8' -> EIGHT + '9' -> NINE + + 'a' -> A + 'b' -> B + 'c' -> C + 'd' -> D + 'e' -> E + 'f' -> F + 'g' -> G + 'h' -> H + 'i' -> I + 'j' -> J + 'k' -> K + 'l' -> L + 'm' -> M + 'n' -> N + 'o' -> O + 'p' -> P + 'q' -> Q + 'r' -> R + 's' -> S + 't' -> T + 'u' -> U + 'v' -> V + 'w' -> W + 'x' -> X + 'y' -> Y + 'z' -> Z + + else -> UNKNOWN + } } fun alignToInt(g: Graphics) { diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt index cbf331d9..601a02b9 100644 --- a/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt +++ b/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt @@ -47,13 +47,12 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { READY("Ready", "The agent is ready to accept connections."), ; - fun statusColor(): JBColor = - when (this) { - READY, AGENT_STARTING_READY, START_TIMEOUT_READY -> JBColor.GREEN - CREATED, START_ERROR, START_TIMEOUT, SHUTDOWN_TIMEOUT -> JBColor.YELLOW - FAILED, DISCONNECTED, TIMEOUT, SHUTDOWN_ERROR -> JBColor.RED - else -> if (JBColor.isBright()) JBColor.LIGHT_GRAY else JBColor.DARK_GRAY - } + fun statusColor(): JBColor = when (this) { + READY, AGENT_STARTING_READY, START_TIMEOUT_READY -> JBColor.GREEN + CREATED, START_ERROR, START_TIMEOUT, SHUTDOWN_TIMEOUT -> JBColor.YELLOW + FAILED, DISCONNECTED, TIMEOUT, SHUTDOWN_ERROR -> JBColor.RED + else -> if (JBColor.isBright()) JBColor.LIGHT_GRAY else JBColor.DARK_GRAY + } /** * Return true if the agent is in a connectable state. diff --git a/src/main/kotlin/com/coder/gateway/sdk/convertors/InstantConverter.kt b/src/main/kotlin/com/coder/gateway/sdk/convertors/InstantConverter.kt index 10f700e0..a1a9f085 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/convertors/InstantConverter.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/convertors/InstantConverter.kt @@ -12,10 +12,9 @@ import java.time.temporal.TemporalAccessor class InstantConverter { @ToJson fun toJson(src: Instant?): String = FORMATTER.format(src) - @FromJson fun fromJson(src: String): Instant? = - FORMATTER.parse(src) { temporal: TemporalAccessor? -> - Instant.from(temporal) - } + @FromJson fun fromJson(src: String): Instant? = FORMATTER.parse(src) { temporal: TemporalAccessor? -> + Instant.from(temporal) + } companion object { private val FORMATTER = DateTimeFormatter.ISO_INSTANT diff --git a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt index b44ffcd3..aa46ba57 100644 --- a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt +++ b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt @@ -188,7 +188,7 @@ open class CoderSettings( * Whether to check for IDE updates. */ val checkIDEUpdate: Boolean - get() = state.checkIDEUpdates + get() = state.checkIDEUpdates /** * Whether to ignore a failed setup command. diff --git a/src/test/fixtures/inputs/wildcard.conf b/src/test/fixtures/inputs/wildcard.conf new file mode 100644 index 00000000..b6468c05 --- /dev/null +++ b/src/test/fixtures/inputs/wildcard.conf @@ -0,0 +1,17 @@ +# --- START CODER JETBRAINS test.coder.invalid +Host coder-jetbrains-test.coder.invalid--* + ProxyCommand /tmp/coder-gateway/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-gateway/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --ssh-host-prefix coder-jetbrains-test.coder.invalid-- %h + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + +Host coder-jetbrains-test.coder.invalid-bg--* + ProxyCommand /tmp/coder-gateway/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-gateway/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --ssh-host-prefix coder-jetbrains-test.coder.invalid-bg-- %h + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains +# --- END CODER JETBRAINS test.coder.invalid diff --git a/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt index 8619f508..5ae754ec 100644 --- a/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt @@ -423,7 +423,7 @@ internal class CoderCLIManagerTest { listOf(workspace), input = null, output = "wildcard", - remove = "blank", + remove = "wildcard", features = Features( wildcardSSH = true, ), @@ -472,6 +472,19 @@ internal class CoderCLIManagerTest { } } + val inputConf = + Path.of("src/test/fixtures/inputs/").resolve(it.remove + ".conf").toFile().readText() + .replace(newlineRe, System.lineSeparator()) + .replace("/tmp/coder-gateway/test.coder.invalid/config", escape(coderConfigPath.toString())) + .replace("/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64", escape(ccm.localBinaryPath.toString())) + .let { conf -> + if (it.sshLogDirectory != null) { + conf.replace("/tmp/coder-gateway/test.coder.invalid/logs", it.sshLogDirectory.toString()) + } else { + conf + } + } + // Add workspaces. ccm.configSsh( it.workspaces.flatMap { ws -> @@ -496,8 +509,7 @@ internal class CoderCLIManagerTest { // Remove is the configuration we expect after removing. assertEquals( settings.sshConfigPath.toFile().readText(), - Path.of("src/test/fixtures/inputs").resolve(it.remove + ".conf").toFile() - .readText().replace(newlineRe, System.lineSeparator()), + inputConf ) } } From 3a9116f4e09ad8b059104b198302274980df8c6f Mon Sep 17 00:00:00 2001 From: Kirill Kalishev Date: Thu, 20 Feb 2025 17:34:29 -0500 Subject: [PATCH 3/8] setup script can communicate an error message to the end user (#538) * setup script can communicate an error message to the end user * review fixes * custom exception class for setup command * better title * changelog update --------- Co-authored-by: Benjamin Peinhardt <61021968+bcpeinhardt@users.noreply.github.com> Co-authored-by: Benjamin --- CHANGELOG.md | 4 ++ .../coder/gateway/CoderGatewayConstants.kt | 1 + .../gateway/CoderRemoteConnectionHandle.kt | 60 ++++++++++++++----- .../gateway/CoderSetupCommandException.kt | 7 +++ .../messages/CoderGatewayBundle.properties | 1 + .../coder/gateway/util/SetupCommandTest.kt | 48 +++++++++++++++ 6 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 src/main/kotlin/com/coder/gateway/CoderSetupCommandException.kt create mode 100644 src/test/kotlin/com/coder/gateway/util/SetupCommandTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a14be9e3..72a54920 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## Unreleased +### Added + +- Added functionality to show setup script error message to the end user. + ### Fixed - Fix bug where wildcard configs would not be written under certain conditions. diff --git a/src/main/kotlin/com/coder/gateway/CoderGatewayConstants.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayConstants.kt index 6344aca6..1defb91d 100644 --- a/src/main/kotlin/com/coder/gateway/CoderGatewayConstants.kt +++ b/src/main/kotlin/com/coder/gateway/CoderGatewayConstants.kt @@ -3,4 +3,5 @@ package com.coder.gateway object CoderGatewayConstants { const val GATEWAY_CONNECTOR_ID = "Coder.Gateway.Connector" const val GATEWAY_RECENT_CONNECTIONS_ID = "Coder.Gateway.Recent.Connections" + const val GATEWAY_SETUP_COMMAND_ERROR = "CODER_SETUP_ERROR" } diff --git a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt index 102b73fc..790a2cd3 100644 --- a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt +++ b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt @@ -2,6 +2,7 @@ package com.coder.gateway +import com.coder.gateway.CoderGatewayConstants.GATEWAY_SETUP_COMMAND_ERROR import com.coder.gateway.cli.CoderCLIManager import com.coder.gateway.models.WorkspaceProjectIDE import com.coder.gateway.models.toIdeWithStatus @@ -160,25 +161,38 @@ class CoderRemoteConnectionHandle { ) logger.info("Adding ${parameters.ideName} for ${parameters.hostname}:${parameters.projectPath} to recent connections") recentConnectionsService.addRecentConnection(parameters.toRecentWorkspaceConnection()) + } catch (e: CoderSetupCommandException) { + logger.error("Failed to run setup command", e) + showConnectionErrorMessage( + e.message ?: "Unknown error", + "gateway.connector.coder.setup-command.failed", + ) } catch (e: Exception) { if (isCancellation(e)) { logger.info("Connection canceled due to ${e.javaClass.simpleName}") } else { logger.error("Failed to connect (will not retry)", e) - // The dialog will close once we return so write the error - // out into a new dialog. - ApplicationManager.getApplication().invokeAndWait { - Messages.showMessageDialog( - e.message ?: e.javaClass.simpleName ?: "Aborted", - CoderGatewayBundle.message("gateway.connector.coder.connection.failed"), - Messages.getErrorIcon(), - ) - } + showConnectionErrorMessage( + e.message ?: e.javaClass.simpleName ?: "Aborted", + "gateway.connector.coder.connection.failed" + ) } } } } + // The dialog will close once we return so write the error + // out into a new dialog. + private fun showConnectionErrorMessage(message: String, titleKey: String) { + ApplicationManager.getApplication().invokeAndWait { + Messages.showMessageDialog( + message, + CoderGatewayBundle.message(titleKey), + Messages.getErrorIcon(), + ) + } + } + /** * Return a new (non-EAP) IDE if we should update. */ @@ -412,18 +426,15 @@ class CoderRemoteConnectionHandle { ) { if (setupCommand.isNotBlank()) { indicator.text = "Running setup command..." - try { + processSetupCommand(ignoreSetupFailure) { exec(workspace, setupCommand) - } catch (ex: Exception) { - if (!ignoreSetupFailure) { - throw ex - } } } else { logger.info("No setup command to run on ${workspace.hostname}") } } + /** * Execute a command in the IDE's bin directory. * This exists since the accessor does not provide a generic exec. @@ -523,5 +534,26 @@ class CoderRemoteConnectionHandle { companion object { val logger = Logger.getInstance(CoderRemoteConnectionHandle::class.java.simpleName) + @Throws(CoderSetupCommandException::class) + fun processSetupCommand( + ignoreSetupFailure: Boolean, + execCommand: () -> String + ) { + try { + val errorText = execCommand + .invoke() + .lines() + .firstOrNull { it.contains(GATEWAY_SETUP_COMMAND_ERROR) } + ?.let { it.substring(it.indexOf(GATEWAY_SETUP_COMMAND_ERROR) + GATEWAY_SETUP_COMMAND_ERROR.length).trim() } + + if (!errorText.isNullOrBlank()) { + throw CoderSetupCommandException(errorText) + } + } catch (ex: Exception) { + if (!ignoreSetupFailure) { + throw CoderSetupCommandException(ex.message ?: "Unknown error", ex) + } + } + } } } diff --git a/src/main/kotlin/com/coder/gateway/CoderSetupCommandException.kt b/src/main/kotlin/com/coder/gateway/CoderSetupCommandException.kt new file mode 100644 index 00000000..e43d9269 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/CoderSetupCommandException.kt @@ -0,0 +1,7 @@ +package com.coder.gateway + +class CoderSetupCommandException : Exception { + + constructor(message: String) : super(message) + constructor(message: String, cause: Throwable) : super(message, cause) +} \ No newline at end of file diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index 4400eb89..f318012e 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -49,6 +49,7 @@ gateway.connector.coder.connection.provider.title=Connecting to Coder workspace. gateway.connector.coder.connecting=Connecting... gateway.connector.coder.connecting.retry=Connecting (attempt {0})... gateway.connector.coder.connection.failed=Failed to connect +gateway.connector.coder.setup-command.failed=Failed to set up backend IDE gateway.connector.coder.connecting.failed.retry=Failed to connect...retrying {0} gateway.connector.settings.data-directory.title=Data directory gateway.connector.settings.data-directory.comment=Directories are created \ diff --git a/src/test/kotlin/com/coder/gateway/util/SetupCommandTest.kt b/src/test/kotlin/com/coder/gateway/util/SetupCommandTest.kt new file mode 100644 index 00000000..b237925b --- /dev/null +++ b/src/test/kotlin/com/coder/gateway/util/SetupCommandTest.kt @@ -0,0 +1,48 @@ +package com.coder.gateway.util + +import com.coder.gateway.CoderRemoteConnectionHandle.Companion.processSetupCommand +import com.coder.gateway.CoderSetupCommandException +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.test.assertEquals + +internal class SetupCommandTest { + + @Test + fun executionErrors() { + assertEquals( + "Execution error", + assertThrows { + processSetupCommand(false) { throw Exception("Execution error") } + }.message + ) + processSetupCommand(true) { throw Exception("Execution error") } + } + + @Test + fun setupScriptError() { + assertEquals( + "Your IDE is expired, please update", + assertThrows { + processSetupCommand(false) { + """ + execution line 1 + execution line 2 + CODER_SETUP_ERRORYour IDE is expired, please update + execution line 3 + """ + } + }.message + ) + + processSetupCommand(true) { + """ + execution line 1 + execution line 2 + CODER_SETUP_ERRORYour IDE is expired, please update + execution line 3 + """ + } + + } +} \ No newline at end of file From 3b357fa0be70b06afabf0335ab96952ea5a3346a Mon Sep 17 00:00:00 2001 From: Benjamin Date: Fri, 21 Feb 2025 09:33:27 -0600 Subject: [PATCH 4/8] v2.19.0 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 9430536f..044ab4ef 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ pluginGroup=com.coder.gateway # Zip file name. pluginName=coder-gateway # SemVer format -> https://semver.org -pluginVersion=2.18.1 +pluginVersion=2.19.0 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. pluginSinceBuild=233.6745 From 64e66d8d83cff8c37e96f22d2626807f03de580a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 6 Mar 2025 21:41:49 -0600 Subject: [PATCH 5/8] Changelog update - v2.19.0 (#540) Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72a54920..52baa52f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## Unreleased +## 2.19.0 - 2025-02-21 + ### Added - Added functionality to show setup script error message to the end user. From 74ad635ca51705ff670b963fe606c42035156578 Mon Sep 17 00:00:00 2001 From: Aaron Lehmann Date: Thu, 6 Mar 2025 20:04:59 -0800 Subject: [PATCH 6/8] Retrieve workspace directly in link handler when using wildcardSSH feature (#542) * Retrieve workspace directly in link handler when using wildcardSSH feature Instead of listing all workspaces matching the filter, get info about the specific workspace the user is trying to connect to. This lets jetbrains-gateway:// links to others' workspaces work without needing to modify the workspace filter parameter. * changelog update --------- Co-authored-by: Benjamin Peinhardt <61021968+bcpeinhardt@users.noreply.github.com> Co-authored-by: Benjamin --- CHANGELOG.md | 4 ++ .../com/coder/gateway/sdk/CoderRestClient.kt | 13 ++++++ .../coder/gateway/sdk/v2/CoderV2RestFacade.kt | 10 +++++ .../com/coder/gateway/util/LinkHandler.kt | 40 ++++++++++--------- 4 files changed, 49 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52baa52f..5782292d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## Unreleased +### Changed + +Retrieve workspace directly in link handler when using wildcardSSH feature + ## 2.19.0 - 2025-02-21 ### Added diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt index 40d5934a..71c6e1ba 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt @@ -176,6 +176,19 @@ open class CoderRestClient( return workspacesResponse.body()!!.workspaces } + /** + * Retrieves a specific workspace by owner and name. + * @throws [APIResponseException]. + */ + fun workspaceByOwnerAndName(owner: String, workspaceName: String): Workspace { + val workspaceResponse = retroRestClient.workspaceByOwnerAndName(owner, workspaceName).execute() + if (!workspaceResponse.isSuccessful) { + throw APIResponseException("retrieve workspace", url, workspaceResponse) + } + + return workspaceResponse.body()!! + } + /** * Retrieves all the agent names for all workspaces, including those that * are off. Meant to be used when configuring SSH. diff --git a/src/main/kotlin/com/coder/gateway/sdk/v2/CoderV2RestFacade.kt b/src/main/kotlin/com/coder/gateway/sdk/v2/CoderV2RestFacade.kt index b610a314..81976ed8 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/v2/CoderV2RestFacade.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/v2/CoderV2RestFacade.kt @@ -4,6 +4,7 @@ import com.coder.gateway.sdk.v2.models.BuildInfo import com.coder.gateway.sdk.v2.models.CreateWorkspaceBuildRequest import com.coder.gateway.sdk.v2.models.Template import com.coder.gateway.sdk.v2.models.User +import com.coder.gateway.sdk.v2.models.Workspace import com.coder.gateway.sdk.v2.models.WorkspaceBuild import com.coder.gateway.sdk.v2.models.WorkspaceResource import com.coder.gateway.sdk.v2.models.WorkspacesResponse @@ -22,6 +23,15 @@ interface CoderV2RestFacade { @GET("api/v2/users/me") fun me(): Call + /** + * Retrieves a specific workspace by owner and name. + */ + @GET("api/v2/users/{user}/workspace/{workspace}") + fun workspaceByOwnerAndName( + @Path("user") user: String, + @Path("workspace") workspace: String, + ): Call + /** * Retrieves all workspaces the authenticated user has access to. */ diff --git a/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt b/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt index fbd5584b..c32a136e 100644 --- a/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt +++ b/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt @@ -57,11 +57,27 @@ open class LinkHandler( // owner is included, assume the current user. val owner = (parameters.owner() ?: client.me.username).ifBlank { client.me.username } - val workspaces = client.workspaces() - val workspace = - workspaces.firstOrNull { - it.ownerName == owner && it.name == workspaceName - } ?: throw IllegalArgumentException("The workspace $workspaceName does not exist") + val cli = + ensureCLI( + deploymentURL.toURL(), + client.buildInfo().version, + settings, + indicator, + ) + + var workspace : Workspace + var workspaces : List = emptyList() + var workspacesAndAgents : Set> = emptySet() + if (cli.features.wildcardSSH) { + workspace = client.workspaceByOwnerAndName(owner, workspaceName) + } else { + workspaces = client.workspaces() + workspace = + workspaces.firstOrNull { + it.ownerName == owner && it.name == workspaceName + } ?: throw IllegalArgumentException("The workspace $workspaceName does not exist") + workspacesAndAgents = client.withAgents(workspaces) + } when (workspace.latestBuild.status) { WorkspaceStatus.PENDING, WorkspaceStatus.STARTING -> @@ -96,14 +112,6 @@ open class LinkHandler( throw IllegalArgumentException("The agent \"${agent.name}\" has a status of \"${status.toString().lowercase()}\"; unable to connect") } - val cli = - ensureCLI( - deploymentURL.toURL(), - client.buildInfo().version, - settings, - indicator, - ) - // We only need to log in if we are using token-based auth. if (client.token != null) { indicator?.invoke("Authenticating Coder CLI...") @@ -111,11 +119,7 @@ open class LinkHandler( } indicator?.invoke("Configuring Coder CLI...") - if (cli.features.wildcardSSH) { - cli.configSsh(workspacesAndAgents = emptySet(), currentUser = client.me) - } else { - cli.configSsh(workspacesAndAgents = client.withAgents(workspaces), currentUser = client.me) - } + cli.configSsh(workspacesAndAgents, currentUser = client.me) val openDialog = parameters.ideProductCode().isNullOrBlank() || From 39a29426e535208053d987562cc874a11925bc54 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Fri, 7 Mar 2025 16:08:06 -0600 Subject: [PATCH 7/8] update version --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 044ab4ef..f7e535c4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ pluginGroup=com.coder.gateway # Zip file name. pluginName=coder-gateway # SemVer format -> https://semver.org -pluginVersion=2.19.0 +pluginVersion=2.20.0 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. pluginSinceBuild=233.6745 From 79118e354636b698e7e3b241f0a7c34a3a1a3ed2 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 1 May 2025 20:54:24 +0300 Subject: [PATCH 8/8] fix: skip installed EAP, RC, NIGHTLY and PREVIEW ides from showing if they are superseded (#548) * chore: build and test with newer Gateway version The existing EAP snapshot was removed from the repo * fix: skip installed EAP, RC, NIGHTLY and PREVIEW ides from showing if they are superseded The IDE and Project view will no longer show IDEs that are installed and which are not yet released, but they have a released version available for download. * impl: add UTs for installed IDEs filtering --- CHANGELOG.md | 6 +- gradle.properties | 2 +- .../gateway/models/WorkspaceProjectIDE.kt | 71 +++- .../steps/CoderWorkspaceProjectIDEStepView.kt | 93 +++-- .../gateway/models/WorkspaceProjectIDETest.kt | 336 ++++++++++++++++++ 5 files changed, 461 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5782292d..7472dd9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,11 @@ ### Changed -Retrieve workspace directly in link handler when using wildcardSSH feature +- Retrieve workspace directly in link handler when using wildcardSSH feature + +### Fixed + +- installed EAP, RC, NIGHTLY and PREVIEW IDEs are no longer displayed if there is a higher released version available for download. ## 2.19.0 - 2025-02-21 diff --git a/gradle.properties b/gradle.properties index f7e535c4..c7842bd4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -26,7 +26,7 @@ pluginUntilBuild=251.* # that exists, ideally the most recent one, for example # 233.15325-EAP-CANDIDATE-SNAPSHOT). platformType=GW -platformVersion=233.15619-EAP-CANDIDATE-SNAPSHOT +platformVersion=241.19416-EAP-CANDIDATE-SNAPSHOT instrumentationCompiler=243.15521-EAP-CANDIDATE-SNAPSHOT # Gateway does not have open sources. platformDownloadSources=true diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceProjectIDE.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceProjectIDE.kt index 3b205554..287f1bd4 100644 --- a/src/main/kotlin/com/coder/gateway/models/WorkspaceProjectIDE.kt +++ b/src/main/kotlin/com/coder/gateway/models/WorkspaceProjectIDE.kt @@ -6,11 +6,14 @@ import com.jetbrains.gateway.ssh.IdeStatus import com.jetbrains.gateway.ssh.IdeWithStatus import com.jetbrains.gateway.ssh.InstalledIdeUIEx import com.jetbrains.gateway.ssh.IntelliJPlatformProduct +import com.jetbrains.gateway.ssh.ReleaseType import com.jetbrains.gateway.ssh.deploy.ShellArgument import java.net.URL import java.nio.file.Path import kotlin.io.path.name +private val NON_STABLE_RELEASE_TYPES = setOf("EAP", "RC", "NIGHTLY", "PREVIEW") + /** * Validated parameters for downloading and opening a project using an IDE on a * workspace. @@ -101,7 +104,8 @@ class WorkspaceProjectIDE( name = name, hostname = hostname, projectPath = projectPath, - ideProduct = IntelliJPlatformProduct.fromProductCode(ideProductCode) ?: throw Exception("invalid product code"), + ideProduct = IntelliJPlatformProduct.fromProductCode(ideProductCode) + ?: throw Exception("invalid product code"), ideBuildNumber = ideBuildNumber, idePathOnHost = idePathOnHost, downloadSource = downloadSource, @@ -126,13 +130,13 @@ fun RecentWorkspaceConnection.toWorkspaceProjectIDE(): WorkspaceProjectIDE { // connections page, so it could be missing. Try to get it from the // host name. name = - if (name.isNullOrBlank() && !hostname.isNullOrBlank()) { - hostname - .removePrefix("coder-jetbrains--") - .removeSuffix("--${hostname.split("--").last()}") - } else { - name - }, + if (name.isNullOrBlank() && !hostname.isNullOrBlank()) { + hostname + .removePrefix("coder-jetbrains--") + .removeSuffix("--${hostname.split("--").last()}") + } else { + name + }, hostname = hostname, projectPath = projectPath, ideProductCode = ideProductCode, @@ -146,17 +150,17 @@ fun RecentWorkspaceConnection.toWorkspaceProjectIDE(): WorkspaceProjectIDE { // the config directory). For backwards compatibility with existing // entries, extract the URL from the config directory or host name. deploymentURL = - if (deploymentURL.isNullOrBlank()) { - if (!dir.isNullOrBlank()) { - "https://${Path.of(dir).parent.name}" - } else if (!hostname.isNullOrBlank()) { - "https://${hostname.split("--").last()}" + if (deploymentURL.isNullOrBlank()) { + if (!dir.isNullOrBlank()) { + "https://${Path.of(dir).parent.name}" + } else if (!hostname.isNullOrBlank()) { + "https://${hostname.split("--").last()}" + } else { + deploymentURL + } } else { deploymentURL - } - } else { - deploymentURL - }, + }, lastOpened = lastOpened, ) } @@ -195,6 +199,39 @@ fun AvailableIde.toIdeWithStatus(): IdeWithStatus = IdeWithStatus( remoteDevType = remoteDevType, ) +/** + * Returns a list of installed IDEs that don't have a RELEASED version available for download. + * Typically, installed EAP, RC, nightly or preview builds should be superseded by released versions. + */ +fun List.filterOutAvailableReleasedIdes(availableIde: List): List { + val availableReleasedByProductCode = availableIde + .filter { it.releaseType == ReleaseType.RELEASE } + .groupBy { it.product.productCode } + val result = mutableListOf() + + this.forEach { installedIde -> + // installed IDEs have the release type embedded in the presentable version + // which is a string in the form: 2024.2.4 NIGHTLY + if (NON_STABLE_RELEASE_TYPES.any { it in installedIde.presentableVersion }) { + // we can show the installed IDe if there isn't a higher released version available for download + if (installedIde.isSNotSupersededBy(availableReleasedByProductCode[installedIde.product.productCode])) { + result.add(installedIde) + } + } else { + result.add(installedIde) + } + } + + return result +} + +private fun InstalledIdeUIEx.isSNotSupersededBy(availableIdes: List?): Boolean { + if (availableIdes.isNullOrEmpty()) { + return true + } + return !availableIdes.any { it.buildNumber >= this.buildNumber } +} + /** * Convert an installed IDE to an IDE with status. */ diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt index 4352cdb5..ce28903a 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt @@ -4,6 +4,7 @@ import com.coder.gateway.CoderGatewayBundle import com.coder.gateway.cli.CoderCLIManager import com.coder.gateway.icons.CoderIcons import com.coder.gateway.models.WorkspaceProjectIDE +import com.coder.gateway.models.filterOutAvailableReleasedIdes import com.coder.gateway.models.toIdeWithStatus import com.coder.gateway.models.withWorkspaceProject import com.coder.gateway.sdk.v2.models.Workspace @@ -82,9 +83,12 @@ import javax.swing.SwingConstants import javax.swing.event.DocumentEvent // Just extracting the way we display the IDE info into a helper function. -private fun displayIdeWithStatus(ideWithStatus: IdeWithStatus): String = "${ideWithStatus.product.productCode} ${ideWithStatus.presentableVersion} ${ideWithStatus.buildNumber} | ${ideWithStatus.status.name.lowercase( - Locale.getDefault(), -)}" +private fun displayIdeWithStatus(ideWithStatus: IdeWithStatus): String = + "${ideWithStatus.product.productCode} ${ideWithStatus.presentableVersion} ${ideWithStatus.buildNumber} | ${ + ideWithStatus.status.name.lowercase( + Locale.getDefault(), + ) + }" /** * View for a single workspace. In particular, show available IDEs and a button @@ -222,12 +226,21 @@ class CoderWorkspaceProjectIDEStepView( cbIDE.renderer = if (attempt > 1) { IDECellRenderer( - CoderGatewayBundle.message("gateway.connector.view.coder.connect-ssh.retry", attempt), + CoderGatewayBundle.message( + "gateway.connector.view.coder.connect-ssh.retry", + attempt + ), ) } else { IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.connect-ssh")) } - val executor = createRemoteExecutor(CoderCLIManager(data.client.url).getBackgroundHostName(data.workspace, data.client.me, data.agent)) + val executor = createRemoteExecutor( + CoderCLIManager(data.client.url).getBackgroundHostName( + data.workspace, + data.client.me, + data.agent + ) + ) if (ComponentValidator.getInstance(tfProject).isEmpty) { logger.info("Installing remote path validator...") @@ -238,7 +251,10 @@ class CoderWorkspaceProjectIDEStepView( cbIDE.renderer = if (attempt > 1) { IDECellRenderer( - CoderGatewayBundle.message("gateway.connector.view.coder.retrieve-ides.retry", attempt), + CoderGatewayBundle.message( + "gateway.connector.view.coder.retrieve-ides.retry", + attempt + ), ) } else { IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.retrieve-ides")) @@ -247,9 +263,9 @@ class CoderWorkspaceProjectIDEStepView( }, retryIf = { it is ConnectionException || - it is TimeoutException || - it is SSHException || - it is DeployException + it is TimeoutException || + it is SSHException || + it is DeployException }, onException = { attempt, nextMs, e -> logger.error("Failed to retrieve IDEs (attempt $attempt; will retry in $nextMs ms)") @@ -311,7 +327,10 @@ class CoderWorkspaceProjectIDEStepView( * Validate the remote path whenever it changes. */ private fun installRemotePathValidator(executor: HighLevelHostAccessor) { - val disposable = Disposer.newDisposable(ApplicationManager.getApplication(), CoderWorkspaceProjectIDEStepView::class.java.name) + val disposable = Disposer.newDisposable( + ApplicationManager.getApplication(), + CoderWorkspaceProjectIDEStepView::class.java.name + ) ComponentValidator(disposable).installOn(tfProject) tfProject.document.addDocumentListener( @@ -324,7 +343,12 @@ class CoderWorkspaceProjectIDEStepView( val isPathPresent = validateRemotePath(tfProject.text, executor) if (isPathPresent.pathOrNull == null) { ComponentValidator.getInstance(tfProject).ifPresent { - it.updateInfo(ValidationInfo("Can't find directory: ${tfProject.text}", tfProject)) + it.updateInfo( + ValidationInfo( + "Can't find directory: ${tfProject.text}", + tfProject + ) + ) } } else { ComponentValidator.getInstance(tfProject).ifPresent { @@ -333,7 +357,12 @@ class CoderWorkspaceProjectIDEStepView( } } catch (e: Exception) { ComponentValidator.getInstance(tfProject).ifPresent { - it.updateInfo(ValidationInfo("Can't validate directory: ${tfProject.text}", tfProject)) + it.updateInfo( + ValidationInfo( + "Can't validate directory: ${tfProject.text}", + tfProject + ) + ) } } } @@ -377,27 +406,34 @@ class CoderWorkspaceProjectIDEStepView( } logger.info("Resolved OS and Arch for $name is: $workspaceOS") - val installedIdesJob = - cs.async(Dispatchers.IO) { - executor.getInstalledIDEs().map { it.toIdeWithStatus() } - } - val idesWithStatusJob = - cs.async(Dispatchers.IO) { - IntelliJPlatformProduct.entries - .filter { it.showInGateway } - .flatMap { CachingProductsJsonWrapper.getInstance().getAvailableIdes(it, workspaceOS) } - .map { it.toIdeWithStatus() } - } + val installedIdesJob = cs.async(Dispatchers.IO) { + executor.getInstalledIDEs() + } + val availableToDownloadIdesJob = cs.async(Dispatchers.IO) { + IntelliJPlatformProduct.entries + .filter { it.showInGateway } + .flatMap { CachingProductsJsonWrapper.getInstance().getAvailableIdes(it, workspaceOS) } + } + + val installedIdes = installedIdesJob.await() + val availableIdes = availableToDownloadIdesJob.await() - val installedIdes = installedIdesJob.await().sorted() - val idesWithStatus = idesWithStatusJob.await().sorted() if (installedIdes.isEmpty()) { logger.info("No IDE is installed in $name") } - if (idesWithStatus.isEmpty()) { + if (availableIdes.isEmpty()) { logger.warn("Could not resolve any IDE for $name, probably $workspaceOS is not supported by Gateway") } - return installedIdes + idesWithStatus + + val remainingInstalledIdes = installedIdes.filterOutAvailableReleasedIdes(availableIdes) + if (remainingInstalledIdes.size < installedIdes.size) { + logger.info( + "Skipping the following list of installed IDEs because there is already a released version " + + "available for download: ${(installedIdes - remainingInstalledIdes).joinToString { "${it.product.productCode} ${it.presentableVersion}" }}" + ) + } + return remainingInstalledIdes.map { it.toIdeWithStatus() }.sorted() + availableIdes.map { it.toIdeWithStatus() } + .sorted() } private fun toDeployedOS( @@ -455,7 +491,8 @@ class CoderWorkspaceProjectIDEStepView( override fun getSelectedItem(): IdeWithStatus? = super.getSelectedItem() as IdeWithStatus? } - private class IDECellRenderer(message: String, cellIcon: Icon = AnimatedIcon.Default.INSTANCE) : ListCellRenderer { + private class IDECellRenderer(message: String, cellIcon: Icon = AnimatedIcon.Default.INSTANCE) : + ListCellRenderer { private val loadingComponentRenderer: ListCellRenderer = object : ColoredListCellRenderer() { override fun customizeCellRenderer( diff --git a/src/test/kotlin/com/coder/gateway/models/WorkspaceProjectIDETest.kt b/src/test/kotlin/com/coder/gateway/models/WorkspaceProjectIDETest.kt index 3a64f6e0..6c6873e5 100644 --- a/src/test/kotlin/com/coder/gateway/models/WorkspaceProjectIDETest.kt +++ b/src/test/kotlin/com/coder/gateway/models/WorkspaceProjectIDETest.kt @@ -1,5 +1,22 @@ package com.coder.gateway.models +import com.jetbrains.gateway.ssh.AvailableIde +import com.jetbrains.gateway.ssh.Download +import com.jetbrains.gateway.ssh.InstalledIdeUIEx +import com.jetbrains.gateway.ssh.IntelliJPlatformProduct +import com.jetbrains.gateway.ssh.IntelliJPlatformProduct.GOIDE +import com.jetbrains.gateway.ssh.IntelliJPlatformProduct.IDEA +import com.jetbrains.gateway.ssh.IntelliJPlatformProduct.IDEA_IC +import com.jetbrains.gateway.ssh.IntelliJPlatformProduct.PYCHARM +import com.jetbrains.gateway.ssh.IntelliJPlatformProduct.RUBYMINE +import com.jetbrains.gateway.ssh.IntelliJPlatformProduct.RUSTROVER +import com.jetbrains.gateway.ssh.ReleaseType +import com.jetbrains.gateway.ssh.ReleaseType.EAP +import com.jetbrains.gateway.ssh.ReleaseType.NIGHTLY +import com.jetbrains.gateway.ssh.ReleaseType.PREVIEW +import com.jetbrains.gateway.ssh.ReleaseType.RC +import com.jetbrains.gateway.ssh.ReleaseType.RELEASE +import org.junit.jupiter.api.DisplayName import java.net.URL import kotlin.test.Test import kotlin.test.assertContains @@ -125,4 +142,323 @@ internal class WorkspaceProjectIDETest { }, ) } + + @Test + @DisplayName("test that installed IDEs filter returns an empty list when there are available IDEs but none are installed") + fun testFilterOutWhenNoIdeIsInstalledButAvailableIsPopulated() { + assertEquals( + emptyList(), emptyList().filterOutAvailableReleasedIdes( + listOf( + availableIde(IDEA, "242.23726.43", EAP), + availableIde(IDEA_IC, "251.23726.43", RELEASE) + ) + ) + ) + } + + @Test + @DisplayName("test that unreleased installed IDEs are not filtered out when available list of IDEs is empty") + fun testFilterOutAvailableReleaseIdesWhenAvailableIsEmpty() { + // given an eap installed ide + val installedEAPs = listOf(installedIde(IDEA, "242.23726.43", EAP)) + + // expect + assertEquals(installedEAPs, installedEAPs.filterOutAvailableReleasedIdes(emptyList())) + + // given an RC installed ide + val installedRCs = listOf(installedIde(RUSTROVER, "243.63726.48", RC)) + + // expect + assertEquals(installedRCs, installedRCs.filterOutAvailableReleasedIdes(emptyList())) + + // given a preview installed ide + val installedPreviews = listOf(installedIde(IDEA_IC, "244.63726.48", ReleaseType.PREVIEW)) + + // expect + assertEquals(installedPreviews, installedPreviews.filterOutAvailableReleasedIdes(emptyList())) + + // given a nightly installed ide + val installedNightlys = listOf(installedIde(RUBYMINE, "244.63726.48", NIGHTLY)) + + // expect + assertEquals(installedNightlys, installedNightlys.filterOutAvailableReleasedIdes(emptyList())) + } + + @Test + @DisplayName("test that unreleased EAP ides are superseded by available RELEASED ides with the same or higher build number") + fun testUnreleasedAndInstalledEAPIdesAreSupersededByAvailableReleasedWithSameOrHigherBuildNr() { + // given an eap installed ide + val installedEapIdea = installedIde(IDEA, "242.23726.43", EAP) + val installedReleasedRustRover = installedIde(RUSTROVER, "251.55667.23", RELEASE) + // and a released idea with same build number + val availableReleasedIdeaWithSameBuild = availableIde(IDEA, "242.23726.43", RELEASE) + + // expect the installed eap idea to be filtered out + assertEquals( + listOf(installedReleasedRustRover), + listOf(installedEapIdea, installedReleasedRustRover).filterOutAvailableReleasedIdes( + listOf( + availableReleasedIdeaWithSameBuild + ) + ) + ) + + // given a released idea with higher build number + val availableIdeaWithHigherBuild = availableIde(IDEA, "243.21726.43", RELEASE) + + // expect the installed eap idea to be filtered out + assertEquals( + listOf(installedReleasedRustRover), + listOf(installedEapIdea, installedReleasedRustRover).filterOutAvailableReleasedIdes( + listOf( + availableIdeaWithHigherBuild + ) + ) + ) + } + + @Test + @DisplayName("test that unreleased RC ides are superseded by available RELEASED ides with the same or higher build number") + fun testUnreleasedAndInstalledRCIdesAreSupersededByAvailableReleasedWithSameOrHigherBuildNr() { + // given an RC installed ide + val installedRCRustRover = installedIde(RUSTROVER, "242.23726.43", RC) + val installedReleasedGoLand = installedIde(GOIDE, "251.55667.23", RELEASE) + // and a released idea with same build number + val availableReleasedRustRoverWithSameBuild = availableIde(RUSTROVER, "242.23726.43", RELEASE) + + // expect the installed RC rust rover to be filtered out + assertEquals( + listOf(installedReleasedGoLand), + listOf(installedRCRustRover, installedReleasedGoLand).filterOutAvailableReleasedIdes( + listOf( + availableReleasedRustRoverWithSameBuild + ) + ) + ) + + // given a released rust rover with higher build number + val availableRustRoverWithHigherBuild = availableIde(RUSTROVER, "243.21726.43", RELEASE) + + // expect the installed RC rust rover to be filtered out + assertEquals( + listOf(installedReleasedGoLand), + listOf(installedRCRustRover, installedReleasedGoLand).filterOutAvailableReleasedIdes( + listOf( + availableRustRoverWithHigherBuild + ) + ) + ) + } + + @Test + @DisplayName("test that unreleased PREVIEW ides are superseded by available RELEASED ides with the same or higher build number") + fun testUnreleasedAndInstalledPreviewIdesAreSupersededByAvailableReleasedWithSameOrHigherBuildNr() { + // given a PREVIEW installed ide + val installedPreviewRubyMine = installedIde(RUBYMINE, "242.23726.43", PREVIEW) + val installedReleasedIntelliJCommunity = installedIde(IDEA_IC, "251.55667.23", RELEASE) + // and a released ruby mine with same build number + val availableReleasedRubyMineWithSameBuild = availableIde(RUBYMINE, "242.23726.43", RELEASE) + + // expect the installed PREVIEW idea to be filtered out + assertEquals( + listOf(installedReleasedIntelliJCommunity), + listOf(installedPreviewRubyMine, installedReleasedIntelliJCommunity).filterOutAvailableReleasedIdes( + listOf( + availableReleasedRubyMineWithSameBuild + ) + ) + ) + + // given a released ruby mine with higher build number + val availableRubyMineWithHigherBuild = availableIde(RUBYMINE, "243.21726.43", RELEASE) + + // expect the installed PREVIEW ruby mine to be filtered out + assertEquals( + listOf(installedReleasedIntelliJCommunity), + listOf(installedPreviewRubyMine, installedReleasedIntelliJCommunity).filterOutAvailableReleasedIdes( + listOf( + availableRubyMineWithHigherBuild + ) + ) + ) + } + + @Test + @DisplayName("test that unreleased NIGHTLY ides are superseded by available RELEASED ides with the same or higher build number") + fun testUnreleasedAndInstalledNightlyIdesAreSupersededByAvailableReleasedWithSameOrHigherBuildNr() { + // given a NIGHTLY installed ide + val installedNightlyPyCharm = installedIde(PYCHARM, "242.23726.43", NIGHTLY) + val installedReleasedRubyMine = installedIde(RUBYMINE, "251.55667.23", RELEASE) + // and a released pycharm with same build number + val availableReleasedPyCharmWithSameBuild = availableIde(PYCHARM, "242.23726.43", RELEASE) + + // expect the installed NIGHTLY pycharm to be filtered out + assertEquals( + listOf(installedReleasedRubyMine), + listOf(installedNightlyPyCharm, installedReleasedRubyMine).filterOutAvailableReleasedIdes( + listOf( + availableReleasedPyCharmWithSameBuild + ) + ) + ) + + // given a released pycharm with higher build number + val availablePyCharmWithHigherBuild = availableIde(PYCHARM, "243.21726.43", RELEASE) + + // expect the installed NIGHTLY pycharm to be filtered out + assertEquals( + listOf(installedReleasedRubyMine), + listOf(installedNightlyPyCharm, installedReleasedRubyMine).filterOutAvailableReleasedIdes( + listOf( + availablePyCharmWithHigherBuild + ) + ) + ) + } + + @Test + @DisplayName("test that unreleased installed ides are NOT superseded by available unreleased IDEs with higher build numbers") + fun testUnreleasedIdesAreNotSupersededByAvailableUnreleasedIdesWithHigherBuildNr() { + // given installed and unreleased ides + val installedEap = listOf(installedIde(RUSTROVER, "203.87675.5", EAP)) + val installedRC = listOf(installedIde(RUSTROVER, "203.87675.5", RC)) + val installedPreview = listOf(installedIde(RUSTROVER, "203.87675.5", PREVIEW)) + val installedNightly = listOf(installedIde(RUSTROVER, "203.87675.5", NIGHTLY)) + + // and available unreleased ides + val availableHigherAndUnreleasedIdes = listOf( + availableIde(RUSTROVER, "204.34567.1", EAP), + availableIde(RUSTROVER, "205.45678.2", RC), + availableIde(RUSTROVER, "206.24667.3", PREVIEW), + availableIde(RUSTROVER, "207.24667.4", NIGHTLY), + ) + + assertEquals( + installedEap, + installedEap.filterOutAvailableReleasedIdes(availableHigherAndUnreleasedIdes) + ) + assertEquals( + installedRC, + installedRC.filterOutAvailableReleasedIdes(availableHigherAndUnreleasedIdes) + ) + assertEquals( + installedPreview, + installedPreview.filterOutAvailableReleasedIdes(availableHigherAndUnreleasedIdes) + ) + assertEquals( + installedNightly, + installedNightly.filterOutAvailableReleasedIdes(availableHigherAndUnreleasedIdes) + ) + } + + @Test + @DisplayName("test that unreleased installed ides are NOT superseded by available unreleased IDEs with same major number but higher minor build numbers") + fun testUnreleasedIdesAreNotSupersededByAvailableUnreleasedIdesWithSameMajorButHigherMinorBuildNr() { + // given installed and unreleased ides + val installedEap = listOf(installedIde(RUSTROVER, "203.12345.5", EAP)) + val installedRC = listOf(installedIde(RUSTROVER, "203.12345.5", RC)) + val installedPreview = listOf(installedIde(RUSTROVER, "203.12345.5", PREVIEW)) + val installedNightly = listOf(installedIde(RUSTROVER, "203.12345.5", NIGHTLY)) + + // and available unreleased ides + val availableHigherAndUnreleasedIdes = listOf( + availableIde(RUSTROVER, "203.34567.1", EAP), + availableIde(RUSTROVER, "203.45678.2", RC), + availableIde(RUSTROVER, "203.24667.3", PREVIEW), + availableIde(RUSTROVER, "203.24667.4", NIGHTLY), + ) + + assertEquals( + installedEap, + installedEap.filterOutAvailableReleasedIdes(availableHigherAndUnreleasedIdes) + ) + assertEquals( + installedRC, + installedRC.filterOutAvailableReleasedIdes(availableHigherAndUnreleasedIdes) + ) + assertEquals( + installedPreview, + installedPreview.filterOutAvailableReleasedIdes(availableHigherAndUnreleasedIdes) + ) + assertEquals( + installedNightly, + installedNightly.filterOutAvailableReleasedIdes(availableHigherAndUnreleasedIdes) + ) + } + + @Test + @DisplayName("test that unreleased installed ides are NOT superseded by available unreleased IDEs with same major and minor number but higher patch numbers") + fun testUnreleasedIdesAreNotSupersededByAvailableUnreleasedIdesWithSameMajorAndMinorButHigherPatchNr() { + // given installed and unreleased ides + val installedEap = listOf(installedIde(RUSTROVER, "203.12345.1", EAP)) + val installedRC = listOf(installedIde(RUSTROVER, "203.12345.1", RC)) + val installedPreview = listOf(installedIde(RUSTROVER, "203.12345.1", PREVIEW)) + val installedNightly = listOf(installedIde(RUSTROVER, "203.12345.1", NIGHTLY)) + + // and available unreleased ides + val availableHigherAndUnreleasedIdes = listOf( + availableIde(RUSTROVER, "203.12345.2", EAP), + availableIde(RUSTROVER, "203.12345.3", RC), + availableIde(RUSTROVER, "203.12345.4", PREVIEW), + availableIde(RUSTROVER, "203.12345.5", NIGHTLY), + ) + + assertEquals( + installedEap, + installedEap.filterOutAvailableReleasedIdes(availableHigherAndUnreleasedIdes) + ) + assertEquals( + installedRC, + installedRC.filterOutAvailableReleasedIdes(availableHigherAndUnreleasedIdes) + ) + assertEquals( + installedPreview, + installedPreview.filterOutAvailableReleasedIdes(availableHigherAndUnreleasedIdes) + ) + assertEquals( + installedNightly, + installedNightly.filterOutAvailableReleasedIdes(availableHigherAndUnreleasedIdes) + ) + } + + companion object { + private val fakeDownload = Download( + "https://download.jetbrains.com/idea/ideaIU-2024.1.7.tar.gz", + 1328462259, + "https://download.jetbrains.com/idea/ideaIU-2024.1.7.tar.gz.sha256" + ) + + private fun installedIde( + product: IntelliJPlatformProduct, + buildNumber: String, + releaseType: ReleaseType + ): InstalledIdeUIEx { + return InstalledIdeUIEx( + product, + buildNumber, + "/home/coder/.cache/JetBrains/", + toPresentableVersion(buildNumber) + " " + releaseType.toString() + ) + } + + private fun availableIde( + product: IntelliJPlatformProduct, + buildNumber: String, + releaseType: ReleaseType + ): AvailableIde { + return AvailableIde( + product, + buildNumber, + fakeDownload, + toPresentableVersion(buildNumber) + " " + releaseType.toString(), + null, + releaseType + ) + } + + private fun toPresentableVersion(buildNr: String): String { + + return "20" + buildNr.substring(0, 2) + "." + buildNr.substring(2, 3) + } + } }