From f9c1af6e15d0c64f28242c11538142eab9cd094f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Dec 2024 22:29:54 +0500 Subject: [PATCH 01/26] chore: bump actions/checkout from 4.2.0 to 4.2.2 (#500) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 6 +++--- .github/workflows/release.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5b4851cc..f4880bcf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,7 @@ jobs: - windows-latest runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v4.2.0 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-java@v4 with: @@ -56,7 +56,7 @@ jobs: steps: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.2 # Setup Java 11 environment for the next steps - name: Setup Java @@ -140,7 +140,7 @@ jobs: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.2 # Remove old release drafts by using the curl request for the available releases with draft flag - name: Remove Old Release Drafts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51d8c128..5e8da9b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v4.2.2 with: ref: ${{ github.event.release.tag_name }} From 83efd4db74dce2e3184c54666228d1c8a8889fa9 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Mon, 16 Dec 2024 22:08:25 +0500 Subject: [PATCH 02/26] chore: update documentation links in UI components (#509) Co-authored-by: Edward Angert --- src/main/kotlin/com/coder/gateway/help/CoderWebHelp.kt | 4 ++-- src/main/kotlin/com/coder/gateway/util/Error.kt | 2 +- .../com/coder/gateway/views/steps/CoderWorkspacesStepView.kt | 2 +- src/main/resources/messages/CoderGatewayBundle.properties | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/help/CoderWebHelp.kt b/src/main/kotlin/com/coder/gateway/help/CoderWebHelp.kt index 3f512ff3..b441cbd1 100644 --- a/src/main/kotlin/com/coder/gateway/help/CoderWebHelp.kt +++ b/src/main/kotlin/com/coder/gateway/help/CoderWebHelp.kt @@ -6,7 +6,7 @@ const val ABOUT_HELP_TOPIC = "com.coder.gateway.about" class CoderWebHelp : WebHelpProvider() { override fun getHelpPageUrl(helpTopicId: String): String = when (helpTopicId) { - ABOUT_HELP_TOPIC -> "https://coder.com/docs/coder-oss/latest" - else -> "https://coder.com/docs/coder-oss/latest" + ABOUT_HELP_TOPIC -> "https://coder.com/docs" + else -> "https://coder.com/docs" } } diff --git a/src/main/kotlin/com/coder/gateway/util/Error.kt b/src/main/kotlin/com/coder/gateway/util/Error.kt index 86bd84ba..b9eff82e 100644 --- a/src/main/kotlin/com/coder/gateway/util/Error.kt +++ b/src/main/kotlin/com/coder/gateway/util/Error.kt @@ -28,7 +28,7 @@ fun humanizeConnectionError(deploymentURL: URL, requireTokenAuth: Boolean, e: Ex } is SocketTimeoutException -> "Unable to connect to $deploymentURL; is it up?" is ResponseException, is ConnectException -> "Failed to download Coder CLI: $reason" - is SSLHandshakeException -> "Connection to $deploymentURL failed: $reason. See the documentation for TLS certificates for information on how to make your system trust certificates coming from your deployment." + is SSLHandshakeException -> "Connection to $deploymentURL failed: $reason. See the documentation for TLS certificates for information on how to make your system trust certificates coming from your deployment." else -> reason } } diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index 3873ef32..45516507 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -202,7 +202,7 @@ class CoderWorkspacesStepView : row { browserLink( CoderGatewayBundle.message("gateway.connector.view.login.documentation.action"), - "https://coder.com/docs/coder-oss/latest/workspaces", + "https://coder.com/docs/user-guides/workspace-management", ) } row(CoderGatewayBundle.message("gateway.connector.view.login.url.label")) { diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index 91791922..0f684b71 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -27,8 +27,8 @@ gateway.connector.view.coder.workspaces.update.description=Update workspace gateway.connector.view.coder.workspaces.create.text=Create Workspace gateway.connector.view.coder.workspaces.create.description=Create workspace gateway.connector.view.coder.workspaces.unsupported.os.info=Gateway supports only Linux machines. Support for macOS and Windows is planned. -gateway.connector.view.coder.workspaces.invalid.coder.version=Could not parse Coder version {0}. Coder Gateway plugin might not be compatible with this version. Connect to a Coder workspace manually -gateway.connector.view.coder.workspaces.unsupported.coder.version=Coder version {0} might not be compatible with this plugin version. Connect to a Coder workspace manually +gateway.connector.view.coder.workspaces.invalid.coder.version=Could not parse Coder version {0}. Coder Gateway plugin might not be compatible with this version. Connect to a Coder workspace manually +gateway.connector.view.coder.workspaces.unsupported.coder.version=Coder version {0} might not be compatible with this plugin version. Connect to a Coder workspace manually gateway.connector.view.workspaces.connect.failed=Connection to {0} failed. See above for details. gateway.connector.view.workspaces.connect.canceled=Connection to {0} canceled. gateway.connector.view.coder.connect-ssh=Establishing SSH connection to remote worker... From 8a896ddde0004f439646336ca52778bf47c39680 Mon Sep 17 00:00:00 2001 From: Aaron Lehmann Date: Thu, 2 Jan 2025 16:42:04 -0800 Subject: [PATCH 03/26] Start workspaces by shelling out to CLI (#518) Replace the REST-API-based start flow with one that shells out to the coder CLI. This is the JetBrains extension equivalent to https://github.com/coder/vscode-coder/pull/400. --- .../com/coder/gateway/cli/CoderCLIManager.kt | 15 +++++++++++++ .../com/coder/gateway/sdk/CoderRestClient.kt | 12 ---------- ...erGatewayRecentWorkspaceConnectionsView.kt | 22 +++++++++++++++++-- .../views/steps/CoderWorkspacesStepView.kt | 6 ++--- 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt index ae1fd6a1..b4ee61e0 100644 --- a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt @@ -451,6 +451,21 @@ class CoderCLIManager( return matches } + /** + * Start a workspace. + * + * Throws if the command execution fails. + */ + fun startWorkspace(workspaceOwner: String, workspaceName: String): String { + return exec( + "--global-config", + coderConfigPath.toString(), + "start", + "--yes", + workspaceOwner+"/"+workspaceName, + ) + } + private fun exec(vararg args: String): String { val stdout = ProcessExecutor() diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt index 9108ed61..40d5934a 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt @@ -227,18 +227,6 @@ open class CoderRestClient( return templateResponse.body()!! } - /** - * @throws [APIResponseException]. - */ - fun startWorkspace(workspace: Workspace): WorkspaceBuild { - val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.START) - val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest).execute() - if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { - throw APIResponseException("start workspace ${workspace.name}", url, buildResponse) - } - return buildResponse.body()!! - } - /** * @throws [APIResponseException]. */ diff --git a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt b/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt index 027f291e..ded8edfa 100644 --- a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt +++ b/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt @@ -5,6 +5,8 @@ package com.coder.gateway.views import com.coder.gateway.CoderGatewayBundle import com.coder.gateway.CoderGatewayConstants import com.coder.gateway.CoderRemoteConnectionHandle +import com.coder.gateway.cli.CoderCLIManager +import com.coder.gateway.cli.ensureCLI import com.coder.gateway.icons.CoderIcons import com.coder.gateway.models.WorkspaceAgentListModel import com.coder.gateway.models.WorkspaceProjectIDE @@ -73,6 +75,8 @@ data class DeploymentInfo( var items: List? = null, // Null if there have not been any errors yet. var error: String? = null, + // Null if unable to ensure the CLI is downloaded. + var cli: CoderCLIManager? = null, ) class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: (Component) -> Unit) : @@ -232,10 +236,10 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: if (enableLinks) { cell( ActionLink(workspaceProjectIDE.projectPathDisplay) { - withoutNull(deployment?.client, workspaceWithAgent?.workspace) { client, workspace -> + withoutNull(deployment?.cli, workspaceWithAgent?.workspace) { cli, workspace -> CoderRemoteConnectionHandle().connect { if (listOf(WorkspaceStatus.STOPPED, WorkspaceStatus.CANCELED, WorkspaceStatus.FAILED).contains(workspace.latestBuild.status)) { - client.startWorkspace(workspace) + cli.startWorkspace(workspace.ownerName, workspace.name) } workspaceProjectIDE } @@ -358,6 +362,19 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: throw Exception("Unable to make request; token was not found in CLI config.") } + val cli = ensureCLI( + deploymentURL.toURL(), + client.buildInfo().version, + settings, + ) + + // We only need to log the cli in if we have token-based auth. + // Otherwise, we assume it is set up in the same way the plugin + // is with mTLS. + if (client.token != null) { + cli.login(client.token) + } + // This is purely to populate the current user, which is // used to match workspaces that were not recorded with owner // information. @@ -378,6 +395,7 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: } deployment.client = client + deployment.cli = cli deployment.items = items deployment.error = null } catch (e: Exception) { diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index 45516507..53a67c37 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -303,13 +303,13 @@ class CoderWorkspacesStepView : CoderIcons.RUN, ) { override fun actionPerformed(p0: AnActionEvent) { - withoutNull(client, tableOfWorkspaces.selectedObject?.workspace) { c, workspace -> + withoutNull(cliManager, tableOfWorkspaces.selectedObject?.workspace) { cliManager, workspace -> jobs[workspace.id]?.cancel() jobs[workspace.id] = cs.launch(ModalityState.current().asContextElement()) { withContext(Dispatchers.IO) { try { - c.startWorkspace(workspace) + cliManager.startWorkspace(workspace.ownerName, workspace.name) loadWorkspaces() } catch (e: Exception) { logger.error("Could not start workspace ${workspace.name}", e) @@ -659,7 +659,7 @@ class CoderWorkspacesStepView : cs.launch(ModalityState.current().asContextElement()) { while (isActive) { loadWorkspaces() - delay(5000) + delay(1000) } } } From 469cb62c1c5f2d7a22e4776dd033241c92518c97 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Jan 2025 10:51:58 -0900 Subject: [PATCH 04/26] Changelog update - v2.15.1 (#493) Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f986d2f0..bdd1b250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## Unreleased +## 2.15.1 - 2024-10-04 + ### Added - Support an "owner" parameter when launching an IDE from the dashboard. This From 791a4c94d2a1f2fb9da68b0b9f8e2f1680d46a1a Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 6 Jan 2025 10:54:21 -0900 Subject: [PATCH 05/26] Update changelog about shelling out to cli --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdd1b250..e6f3f830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ## Unreleased +### Changed + +- When starting a workspace, shell out to the Coder binary instead of making an + API call. This reduces drift between what the plugin does and the CLI does. +- Increase workspace polling to one second on the workspace list view, to pick + up changes made via the CLI faster. The recent connections view remains + unchanged at five seconds. + ## 2.15.1 - 2024-10-04 ### Added From 00722f976758f7a6c0805d36b3c46903fe1b75c1 Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 6 Jan 2025 11:04:16 -0900 Subject: [PATCH 06/26] v2.15.2 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 25fd1805..8341cab0 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.15.1 +pluginVersion=2.15.2 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. pluginSinceBuild=233.6745 From faddd24185949d714a587e8919f637888497feab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Jan 2025 12:32:42 -0900 Subject: [PATCH 07/26] Changelog update - v2.15.2 (#520) Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6f3f830..33c14236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## Unreleased +## 2.15.2 - 2025-01-06 + ### Changed - When starting a workspace, shell out to the Coder binary instead of making an From d27a65e36d297a9e43a7f666ab722f92096d2621 Mon Sep 17 00:00:00 2001 From: Benjamin Peinhardt <61021968+bcpeinhardt@users.noreply.github.com> Date: Fri, 17 Jan 2025 16:26:36 -0600 Subject: [PATCH 08/26] Initial impl of defaultIde selection setting (#522) defaultIde selection setting --- CHANGELOG.md | 6 ++++ .../gateway/CoderSettingsConfigurable.kt | 8 +++++ .../coder/gateway/settings/CoderSettings.kt | 8 +++++ .../steps/CoderWorkspaceProjectIDEStepView.kt | 32 ++++++++++++++++--- .../messages/CoderGatewayBundle.properties | 1 + 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33c14236..6bb23337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## Unreleased +### Added + +- Added setting "Default IDE Selection" which will look for a matching IDE + code/version/build number to set as the preselected IDE in the select + component. + ## 2.15.2 - 2025-01-06 ### Changed diff --git a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt index 5b69f39e..9bd02350 100644 --- a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt +++ b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt @@ -149,6 +149,14 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") { .bindText(state::workspaceFilter) .comment(CoderGatewayBundle.message("gateway.connector.settings.workspace-filter.comment")) }.layout(RowLayout.PARENT_GRID) + row(CoderGatewayBundle.message("gateway.connector.settings.default-ide")) { + textField().resizableColumn().align(AlignX.FILL) + .bindText(state::defaultIde) + .comment( + "The default IDE version to display in the IDE selection dropdown. " + + "Example format: CL 2023.3.6 233.15619.8", + ) + } } } diff --git a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt index 4a33a091..492db8e6 100644 --- a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt +++ b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt @@ -100,6 +100,8 @@ open class CoderSettingsState( open var sshLogDirectory: String = "", // Default filter for fetching workspaces open var workspaceFilter: String = "owner:me", + // Default version of IDE to display in IDE selection dropdown + open var defaultIde: String = "", ) /** @@ -174,6 +176,12 @@ open class CoderSettings( val setupCommand: String get() = state.setupCommand + /** + * The default IDE version to display in the selection menu + */ + val defaultIde: String + get() = state.defaultIde + /** * Whether to ignore a failed setup command. */ 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 8478e355..69709018 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt @@ -8,6 +8,7 @@ import com.coder.gateway.models.toIdeWithStatus import com.coder.gateway.models.withWorkspaceProject import com.coder.gateway.sdk.v2.models.Workspace import com.coder.gateway.sdk.v2.models.WorkspaceAgent +import com.coder.gateway.services.CoderSettingsService import com.coder.gateway.util.Arch import com.coder.gateway.util.OS import com.coder.gateway.util.humanizeDuration @@ -20,6 +21,7 @@ import com.coder.gateway.views.LazyBrowserLink import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState import com.intellij.openapi.application.asContextElement +import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.ui.ComponentValidator @@ -79,6 +81,11 @@ import javax.swing.ListCellRenderer 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(), +)}" + /** * View for a single workspace. In particular, show available IDEs and a button * to select an IDE and project to run on the workspace. @@ -88,6 +95,8 @@ class CoderWorkspaceProjectIDEStepView( ) : CoderWizardStep( CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.next.text"), ) { + private val settings: CoderSettingsService = service() + private val cs = CoroutineScope(Dispatchers.IO) private var ideComboBoxModel = DefaultComboBoxModel() private var state: CoderWorkspacesStepSelection? = null @@ -258,9 +267,24 @@ class CoderWorkspaceProjectIDEStepView( ) }, ) + + // Check the provided setting to see if there's a default IDE to set. + val defaultIde = ides.find { it -> + // Using contains on the displayable version of the ide means they can be as specific or as vague as they want + // CL 2023.3.6 233.15619.8 -> a specific Clion build + // CL 2023.3.6 -> a specific Clion version + // 2023.3.6 -> a specific version (some customers will only have one specific IDE in their list anyway) + if (settings.defaultIde.isEmpty()) { + false + } else { + displayIdeWithStatus(it).contains(settings.defaultIde) + } + } + val index = ides.indexOf(defaultIde ?: ides.firstOrNull()) + withContext(Dispatchers.IO) { ideComboBoxModel.addAll(ides) - cbIDE.selectedIndex = 0 + cbIDE.selectedIndex = index } } catch (e: Exception) { if (isCancellation(e)) { @@ -457,9 +481,9 @@ class CoderWorkspaceProjectIDEStepView( add(JLabel(ideWithStatus.product.ideName, ideWithStatus.product.icon, SwingConstants.LEFT)) add( JLabel( - "${ideWithStatus.product.productCode} ${ideWithStatus.presentableVersion} ${ideWithStatus.buildNumber} | ${ideWithStatus.status.name.lowercase( - Locale.getDefault(), - )}", + displayIdeWithStatus( + ideWithStatus, + ), ).apply { foreground = UIUtil.getLabelDisabledForeground() }, diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index 0f684b71..71000593 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -138,3 +138,4 @@ gateway.connector.settings.workspace-filter.comment=The filter to apply when \ the plugin fetches resources individually for each non-running workspace, \ which can be slow with many workspaces, and it adds every agent to the SSH \ config, which can result in a large SSH config with many workspaces. +gateway.connector.settings.default-ide=Default IDE Selection From fd561e226b1cd067119bc0b18e225cf018579b8d Mon Sep 17 00:00:00 2001 From: Benjamin Date: Fri, 17 Jan 2025 16:30:50 -0600 Subject: [PATCH 09/26] Update version. --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 8341cab0..3c721230 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.15.2 +pluginVersion=2.16.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 077fd6f023590ebfdeb5a9d32d8d30d8c031dbed Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Jan 2025 16:44:51 -0600 Subject: [PATCH 10/26] Changelog update - v2.16.0 (#523) Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb23337..aec72e9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## Unreleased +## 2.16.0 - 2025-01-17 + ### Added - Added setting "Default IDE Selection" which will look for a matching IDE From ac56609570fa34a605b59fd8812ebc3a5b24f634 Mon Sep 17 00:00:00 2001 From: Aaron Lehmann Date: Mon, 27 Jan 2025 15:24:32 -0800 Subject: [PATCH 11/26] Add "Check for IDE updates" setting (#525) * Add "Check for IDE updates" setting This setting controls whether the plugin checks for available backend IDE upgrades (on by default). Turning it off suppresses the prompt to upgrade to a newer version. * Add "Check for IDE updates" setting to CHANGELOG.md --- CHANGELOG.md | 3 +++ .../com/coder/gateway/CoderRemoteConnectionHandle.kt | 2 +- .../kotlin/com/coder/gateway/CoderSettingsConfigurable.kt | 7 +++++++ .../kotlin/com/coder/gateway/settings/CoderSettings.kt | 8 ++++++++ src/main/resources/messages/CoderGatewayBundle.properties | 6 ++++++ 5 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aec72e9a..0da52dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## Unreleased +- Added setting "Check for IDE updates" which controls whether the plugin + checks and prompts for available IDE backend updates. + ## 2.16.0 - 2025-01-17 ### Added diff --git a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt index d71c5f79..102b73fc 100644 --- a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt +++ b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt @@ -93,7 +93,7 @@ class CoderRemoteConnectionHandle { }, true, ) - if (attempt == 1) { + if (settings.checkIDEUpdate && attempt == 1) { // See if there is a newer (non-EAP) version of the IDE available. checkUpdate(accessor, parameters, indicator)?.let { update -> // Store the old IDE to delete later. diff --git a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt index 9bd02350..8b51c704 100644 --- a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt +++ b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt @@ -157,6 +157,13 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") { "Example format: CL 2023.3.6 233.15619.8", ) } + row(CoderGatewayBundle.message("gateway.connector.settings.check-ide-updates.heading")) { + checkBox(CoderGatewayBundle.message("gateway.connector.settings.check-ide-updates.title")) + .bindSelected(state::checkIDEUpdates) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.check-ide-updates.comment"), + ) + }.layout(RowLayout.PARENT_GRID) } } diff --git a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt index 492db8e6..b44ffcd3 100644 --- a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt +++ b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt @@ -102,6 +102,8 @@ open class CoderSettingsState( open var workspaceFilter: String = "owner:me", // Default version of IDE to display in IDE selection dropdown open var defaultIde: String = "", + // Whether to check for IDE updates. + open var checkIDEUpdates: Boolean = true, ) /** @@ -182,6 +184,12 @@ open class CoderSettings( val defaultIde: String get() = state.defaultIde + /** + * Whether to check for IDE updates. + */ + val checkIDEUpdate: Boolean + get() = state.checkIDEUpdates + /** * Whether to ignore a failed setup command. */ diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index 71000593..4400eb89 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -139,3 +139,9 @@ gateway.connector.settings.workspace-filter.comment=The filter to apply when \ which can be slow with many workspaces, and it adds every agent to the SSH \ config, which can result in a large SSH config with many workspaces. gateway.connector.settings.default-ide=Default IDE Selection +gateway.connector.settings.check-ide-updates.heading=IDE version check +gateway.connector.settings.check-ide-updates.title=Check for IDE updates +gateway.connector.settings.check-ide-updates.comment=Checking this box will \ + cause the plugin to check for available IDE backend updates and prompt \ + with an option to upgrade if a newer version is available. + From 0fe524af6daa4e3aac32435b817a987911a11ed8 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Mon, 27 Jan 2025 17:26:31 -0600 Subject: [PATCH 12/26] Update version. --- CHANGELOG.md | 2 ++ gradle.properties | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0da52dab..4667d359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## Unreleased +### Added + - Added setting "Check for IDE updates" which controls whether the plugin checks and prompts for available IDE backend updates. diff --git a/gradle.properties b/gradle.properties index 3c721230..00017f7f 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.16.0 +pluginVersion=2.17.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 9705045fca5b39b69420c38f5b768caba9431f58 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 17:42:34 -0600 Subject: [PATCH 13/26] Changelog update - v2.17.0 (#527) Co-authored-by: GitHub Action --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4667d359..6db57750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## Unreleased +## 2.17.0 - 2025-01-27 + ### Added - Added setting "Check for IDE updates" which controls whether the plugin From f39e1f452a32b8d17ce6ffe2bf9f4afb215596cb Mon Sep 17 00:00:00 2001 From: Aaron Lehmann Date: Tue, 4 Feb 2025 12:48:00 -0800 Subject: [PATCH 14/26] Use wildcard SSH config Host entries (#521) * Use wildcard SSH config Host entries This simplifies the written SSH config and avoids the need to make an API request for every workspace the filter returns. This can remove minutes from the "Configuring Coder CLI..." step when the user has access to many workspaces (for example, an admin who wants the option of connecting to anyone's workspace on a large deployment). Depends on https://github.com/coder/coder/pull/16088 * changelog update --------- Co-authored-by: Benjamin Peinhardt <61021968+bcpeinhardt@users.noreply.github.com> Co-authored-by: Benjamin --- CHANGELOG.md | 4 + .../com/coder/gateway/cli/CoderCLIManager.kt | 122 ++++++++++++------ .../com/coder/gateway/util/LinkHandler.kt | 8 +- .../steps/CoderWorkspaceProjectIDEStepView.kt | 10 +- src/test/fixtures/outputs/wildcard.conf | 17 +++ .../coder/gateway/cli/CoderCLIManagerTest.kt | 11 +- 6 files changed, 128 insertions(+), 44 deletions(-) create mode 100644 src/test/fixtures/outputs/wildcard.conf diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db57750..e10e913d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## Unreleased +### Changed + +- Simplifies the written SSH config and avoids the need to make an API request for every workspace the filter returns. + ## 2.17.0 - 2025-01-27 ### Added diff --git a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt index b4ee61e0..43083c62 100644 --- a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt @@ -115,6 +115,7 @@ fun ensureCLI( data class Features( val disableAutostart: Boolean = false, val reportWorkspaceUsage: Boolean = false, + val wildcardSSH: Boolean = false, ) /** @@ -285,37 +286,57 @@ class CoderCLIManager( } else { "" } + val sshOpts = """ + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + """.trimIndent() val blockContent = + if (feats.wildcardSSH) { + startBlock + System.lineSeparator() + + """ + Host ${getHostPrefix()}--* + ProxyCommand ${proxyArgs.joinToString(" ")} --ssh-host-prefix ${getHostPrefix()}-- %h + """.trimIndent() + .plus("\n" + sshOpts.prependIndent(" ")) + .plus(extraConfig) + .plus("\n\n") + .plus( + """ + Host ${getHostPrefix()}-bg--* + ProxyCommand ${backgroundProxyArgs.joinToString(" ")} --ssh-host-prefix ${getHostPrefix()}-bg-- %h + """.trimIndent() + .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 = { """ - Host ${getHostName(deploymentURL, it.first, currentUser, it.second)} + Host ${getHostName(it.first, currentUser, it.second)} ProxyCommand ${proxyArgs.joinToString(" ")} ${getWorkspaceParts(it.first, it.second)} - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains """.trimIndent() + .plus("\n" + sshOpts.prependIndent(" ")) .plus(extraConfig) .plus("\n") .plus( """ - Host ${getBackgroundHostName(deploymentURL, it.first, currentUser, it.second)} + Host ${getBackgroundHostName(it.first, currentUser, it.second)} ProxyCommand ${backgroundProxyArgs.joinToString(" ")} ${getWorkspaceParts(it.first, it.second)} - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains """.trimIndent() + .plus("\n" + sshOpts.prependIndent(" ")) .plus(extraConfig), ).replace("\n", System.lineSeparator()) }, ) + } if (contents == null) { logger.info("No existing SSH config to modify") @@ -489,40 +510,53 @@ class CoderCLIManager( Features( disableAutostart = version >= SemVer(2, 5, 0), reportWorkspaceUsage = version >= SemVer(2, 13, 0), + wildcardSSH = version >= SemVer(2, 19, 0), ) } } + /* + * This function returns the ssh-host-prefix used for Host entries. + */ + fun getHostPrefix(): String = + "coder-jetbrains-${deploymentURL.safeHost()}" + + /** + * This function returns the ssh host name generated for connecting to the workspace. + */ + fun getHostName( + workspace: Workspace, + currentUser: User, + agent: WorkspaceAgent, + ): 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 { + "coder-jetbrains--${workspace.ownerName}--${workspace.name}.${agent.name}--${deploymentURL.safeHost()}" + } + } + + fun getBackgroundHostName( + workspace: Workspace, + currentUser: User, + agent: WorkspaceAgent, + ): 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) private val tokenRegex = "--token [^ ]+".toRegex() - /** - * This function returns the ssh host name generated for connecting to the workspace. - */ - @JvmStatic - fun getHostName( - url: URL, - workspace: Workspace, - currentUser: User, - agent: WorkspaceAgent, - ): String = - // 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}--${url.safeHost()}" - } else { - "coder-jetbrains--${workspace.ownerName}--${workspace.name}.${agent.name}--${url.safeHost()}" - } - - fun getBackgroundHostName( - url: URL, - workspace: Workspace, - currentUser: User, - agent: WorkspaceAgent, - ): String = getHostName(url, workspace, currentUser, agent) + "--bg" - /** * This function returns the identifier for the workspace to pass to the * coder ssh proxy command. @@ -536,6 +570,18 @@ class CoderCLIManager( @JvmStatic fun getBackgroundHostName( hostname: String, - ): String = hostname + "--bg" + ): String { + val parts = hostname.split("--").toMutableList() + if (parts.size < 2) { + throw SSHConfigFormatException("Invalid hostname: $hostname") + } + // non-wildcard case + if (parts[0] == "coder-jetbrains") { + return hostname + "--bg" + } + // wildcard case + parts[0] += "-bg" + return parts.joinToString("--") + } } } diff --git a/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt b/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt index 22c0e3b3..fbd5584b 100644 --- a/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt +++ b/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt @@ -111,7 +111,11 @@ open class LinkHandler( } indicator?.invoke("Configuring Coder CLI...") - cli.configSsh(workspacesAndAgents = client.withAgents(workspaces), currentUser = client.me) + if (cli.features.wildcardSSH) { + cli.configSsh(workspacesAndAgents = emptySet(), currentUser = client.me) + } else { + cli.configSsh(workspacesAndAgents = client.withAgents(workspaces), currentUser = client.me) + } val openDialog = parameters.ideProductCode().isNullOrBlank() || @@ -127,7 +131,7 @@ open class LinkHandler( verifyDownloadLink(parameters) WorkspaceProjectIDE.fromInputs( name = CoderCLIManager.getWorkspaceParts(workspace, agent), - hostname = CoderCLIManager.getHostName(deploymentURL.toURL(), workspace, client.me, agent), + hostname = CoderCLIManager(deploymentURL.toURL(), settings).getHostName(workspace, client.me, agent), projectPath = parameters.folder(), ideProductCode = parameters.ideProductCode(), ideBuildNumber = parameters.ideBuildNumber(), 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 69709018..4352cdb5 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt @@ -208,7 +208,11 @@ class CoderWorkspaceProjectIDEStepView( logger.info("Configuring Coder CLI...") cbIDE.renderer = IDECellRenderer("Configuring Coder CLI...") withContext(Dispatchers.IO) { - data.cliManager.configSsh(data.client.withAgents(data.workspaces), data.client.me) + if (data.cliManager.features.wildcardSSH) { + data.cliManager.configSsh(emptySet(), data.client.me) + } else { + data.cliManager.configSsh(data.client.withAgents(data.workspaces), data.client.me) + } } val ides = @@ -223,7 +227,7 @@ class CoderWorkspaceProjectIDEStepView( } else { IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.connect-ssh")) } - val executor = createRemoteExecutor(CoderCLIManager.getBackgroundHostName(data.client.url, 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...") @@ -428,7 +432,7 @@ class CoderWorkspaceProjectIDEStepView( override fun data(): WorkspaceProjectIDE = withoutNull(cbIDE.selectedItem, state) { selectedIDE, state -> selectedIDE.withWorkspaceProject( name = CoderCLIManager.getWorkspaceParts(state.workspace, state.agent), - hostname = CoderCLIManager.getHostName(state.client.url, state.workspace, state.client.me, state.agent), + hostname = CoderCLIManager(state.client.url).getHostName(state.workspace, state.client.me, state.agent), projectPath = tfProject.text, deploymentURL = state.client.url, ) diff --git a/src/test/fixtures/outputs/wildcard.conf b/src/test/fixtures/outputs/wildcard.conf new file mode 100644 index 00000000..b6468c05 --- /dev/null +++ b/src/test/fixtures/outputs/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 7abc4f44..8619f508 100644 --- a/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt @@ -419,6 +419,15 @@ internal class CoderCLIManagerTest { output = "multiple-agents", remove = "blank", ), + SSHTest( + listOf(workspace), + input = null, + output = "wildcard", + remove = "blank", + features = Features( + wildcardSSH = true, + ), + ), ) val newlineRe = "\r?\n".toRegex() @@ -804,7 +813,7 @@ internal class CoderCLIManagerTest { listOf( Pair("2.5.0", Features(true)), Pair("2.13.0", Features(true, true)), - Pair("4.9.0", Features(true, true)), + Pair("4.9.0", Features(true, true, true)), Pair("2.4.9", Features(false)), Pair("1.0.1", Features(false)), ) From 5a4fb5950fbb561d0d79d11a0f45b4c8f9901147 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Tue, 4 Feb 2025 15:33:14 -0600 Subject: [PATCH 15/26] v2.18.0 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 00017f7f..2b38322f 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.17.0 +pluginVersion=2.18.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 f182e8469cf0d9ea5313caee1dfacea254db3768 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Feb 2025 16:01:42 -0600 Subject: [PATCH 16/26] Changelog update - v2.18.0 (#529) Co-authored-by: GitHub Action --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e10e913d..7c8cd1be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,11 @@ ## Unreleased +## 2.18.0 - 2025-02-04 + ### Changed -- Simplifies the written SSH config and avoids the need to make an API request for every workspace the filter returns. +- Simplifies the written SSH config and avoids the need to make an API request for every workspace the filter returns. ## 2.17.0 - 2025-01-27 From 9b8754a7a7d49142d20d9171aed6e03513a2a417 Mon Sep 17 00:00:00 2001 From: Benjamin Peinhardt <61021968+bcpeinhardt@users.noreply.github.com> Date: Fri, 14 Feb 2025 16:28:37 -0600 Subject: [PATCH 17/26] fix: update EAP constraint (#535) * Update version. * Update changelog --- CHANGELOG.md | 4 ++++ gradle.properties | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c8cd1be..5870adca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## Unreleased +### Changed + +- Update the `pluginUntilBuild` to latest EAP + ## 2.18.0 - 2025-02-04 ### Changed diff --git a/gradle.properties b/gradle.properties index 2b38322f..e3f655f1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ pluginVersion=2.18.0 pluginSinceBuild=233.6745 # This should be kept up to date with the latest EAP. If the API is incompatible # with the latest stable, use the eap branch temporarily instead. -pluginUntilBuild=243.* +pluginUntilBuild=251.* # IntelliJ Platform Properties -> https://github.com/JetBrains/gradle-intellij-plugin#intellij-platform-properties # Gateway available build versions https://www.jetbrains.com/intellij-repository/snapshots and https://www.jetbrains.com/intellij-repository/releases # From cba83d27ea386d246988f799e11cc596078c0f97 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Fri, 14 Feb 2025 16:30:51 -0600 Subject: [PATCH 18/26] v2.18.1 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index e3f655f1..9430536f 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.0 +pluginVersion=2.18.1 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. pluginSinceBuild=233.6745 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 19/26] 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 20/26] 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 21/26] 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 22/26] 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 23/26] 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 24/26] 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 25/26] 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 26/26] 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) + } + } }