From bc0ce3fd69979892096776abac793ea6b3d54f94 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 3 Aug 2023 11:03:39 -0800 Subject: [PATCH 01/14] Break connection code out into separate class The connect entrypoint is used both for Gateway links (jetbrains-gateway://) and internally in our own code at the end of the wizard and for recent connections, but this will be two separate sets of parameters (internally we have the CLI set up and pass around the hostname while the links will only have the workspace ID or name). So break out the code into a separate class that we can call internally which will let us dedicate the connect entrypoint to handle the Gateway links. There we will set up the CLI and gather the required parameters before calling the now-broken-out code. --- .../gateway/CoderGatewayConnectionProvider.kt | 79 +--------------- .../gateway/CoderRemoteConnectionHandle.kt | 92 +++++++++++++++++++ ...erGatewayRecentWorkspaceConnectionsView.kt | 4 +- .../steps/CoderLocateRemoteProjectStepView.kt | 4 +- 4 files changed, 100 insertions(+), 79 deletions(-) create mode 100644 src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt diff --git a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt index cd625208..90a354f5 100644 --- a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt +++ b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt @@ -2,90 +2,15 @@ package com.coder.gateway -import com.coder.gateway.sdk.humanizeDuration -import com.coder.gateway.sdk.isCancellation -import com.coder.gateway.sdk.isWorkerTimeout -import com.coder.gateway.sdk.suspendingRetryWithExponentialBackOff -import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.rd.util.launchUnderBackgroundProgress -import com.intellij.openapi.ui.Messages import com.jetbrains.gateway.api.ConnectionRequestor import com.jetbrains.gateway.api.GatewayConnectionHandle import com.jetbrains.gateway.api.GatewayConnectionProvider -import com.jetbrains.gateway.api.GatewayUI -import com.jetbrains.gateway.ssh.SshDeployFlowUtil -import com.jetbrains.gateway.ssh.SshMultistagePanelContext -import com.jetbrains.gateway.ssh.deploy.DeployException -import com.jetbrains.rd.util.lifetime.LifetimeDefinition -import kotlinx.coroutines.launch -import net.schmizz.sshj.common.SSHException -import net.schmizz.sshj.connection.ConnectionException -import java.time.Duration -import java.util.concurrent.TimeoutException class CoderGatewayConnectionProvider : GatewayConnectionProvider { - private val recentConnectionsService = service() - override suspend fun connect(parameters: Map, requestor: ConnectionRequestor): GatewayConnectionHandle? { - val clientLifetime = LifetimeDefinition() - // TODO: If this fails determine if it is an auth error and if so prompt - // for a new token, configure the CLI, then try again. - clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title"), canBeCancelled = true, isIndeterminate = true, project = null) { - try { - indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting") - val context = suspendingRetryWithExponentialBackOff( - action = { attempt -> - logger.info("Connecting... (attempt $attempt") - if (attempt > 1) { - // indicator.text is the text above the progress bar. - indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting.retry", attempt) - } - SshMultistagePanelContext(parameters.toHostDeployInputs()) - }, - retryIf = { - it is ConnectionException || it is TimeoutException - || it is SSHException || it is DeployException - }, - onException = { attempt, nextMs, e -> - logger.error("Failed to connect (attempt $attempt; will retry in $nextMs ms)") - // indicator.text2 is the text below the progress bar. - indicator.text2 = - if (isWorkerTimeout(e)) "Failed to upload worker binary...it may have timed out" - else e.message ?: CoderGatewayBundle.message("gateway.connector.no-details") - }, - onCountdown = { remainingMs -> - indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting.failed.retry", humanizeDuration(remainingMs)) - }, - ) - launch { - logger.info("Deploying and starting IDE with $context") - // At this point JetBrains takes over with their own UI. - @Suppress("UnstableApiUsage") SshDeployFlowUtil.fullDeployCycle( - clientLifetime, context, Duration.ofMinutes(10) - ) - } - } catch (e: Exception) { - if (isCancellation(e)) { - logger.info("Connection canceled due to ${e.javaClass}") - } else { - logger.info("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 ?: CoderGatewayBundle.message("gateway.connector.no-details"), - CoderGatewayBundle.message("gateway.connector.coder.connection.failed"), - Messages.getErrorIcon()) - } - } - } - } - - recentConnectionsService.addRecentConnection(parameters.toRecentWorkspaceConnection()) - GatewayUI.getInstance().reset() + logger.debug("Launched Coder connection provider", parameters) + CoderRemoteConnectionHandle().connect(parameters) return null } diff --git a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt new file mode 100644 index 00000000..bf1dc02b --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt @@ -0,0 +1,92 @@ +@file:Suppress("DialogTitleCapitalization") + +package com.coder.gateway + +import com.coder.gateway.sdk.humanizeDuration +import com.coder.gateway.sdk.isCancellation +import com.coder.gateway.sdk.isWorkerTimeout +import com.coder.gateway.sdk.suspendingRetryWithExponentialBackOff +import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.rd.util.launchUnderBackgroundProgress +import com.intellij.openapi.ui.Messages +import com.jetbrains.gateway.ssh.SshDeployFlowUtil +import com.jetbrains.gateway.ssh.SshMultistagePanelContext +import com.jetbrains.gateway.ssh.deploy.DeployException +import com.jetbrains.rd.util.lifetime.LifetimeDefinition +import kotlinx.coroutines.launch +import net.schmizz.sshj.common.SSHException +import net.schmizz.sshj.connection.ConnectionException +import java.time.Duration +import java.util.concurrent.TimeoutException + +// CoderRemoteConnection uses the provided workspace SSH parameters to launch an +// IDE against the workspace. If successful the connection is added to recent +// connections. +class CoderRemoteConnectionHandle { + private val recentConnectionsService = service() + + suspend fun connect(parameters: Map) { + logger.debug("Creating connection handle", parameters) + val clientLifetime = LifetimeDefinition() + // TODO: If this fails determine if it is an auth error and if so prompt + // for a new token, configure the CLI, then try again. + clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title"), canBeCancelled = true, isIndeterminate = true, project = null) { + try { + indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting") + val context = suspendingRetryWithExponentialBackOff( + action = { attempt -> + logger.info("Connecting... (attempt $attempt") + if (attempt > 1) { + // indicator.text is the text above the progress bar. + indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting.retry", attempt) + } + SshMultistagePanelContext(parameters.toHostDeployInputs()) + }, + retryIf = { + it is ConnectionException || it is TimeoutException + || it is SSHException || it is DeployException + }, + onException = { attempt, nextMs, e -> + logger.error("Failed to connect (attempt $attempt; will retry in $nextMs ms)") + // indicator.text2 is the text below the progress bar. + indicator.text2 = + if (isWorkerTimeout(e)) "Failed to upload worker binary...it may have timed out" + else e.message ?: CoderGatewayBundle.message("gateway.connector.no-details") + }, + onCountdown = { remainingMs -> + indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting.failed.retry", humanizeDuration(remainingMs)) + }, + ) + launch { + logger.info("Deploying and starting IDE with $context") + // At this point JetBrains takes over with their own UI. + @Suppress("UnstableApiUsage") SshDeployFlowUtil.fullDeployCycle( + clientLifetime, context, Duration.ofMinutes(10) + ) + } + recentConnectionsService.addRecentConnection(parameters.toRecentWorkspaceConnection()) + } catch (e: Exception) { + if (isCancellation(e)) { + logger.info("Connection canceled due to ${e.javaClass}") + } else { + logger.info("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 ?: CoderGatewayBundle.message("gateway.connector.no-details"), + CoderGatewayBundle.message("gateway.connector.coder.connection.failed"), + Messages.getErrorIcon()) + } + } + } + } + } + + companion object { + val logger = Logger.getInstance(CoderRemoteConnectionHandle::class.java.simpleName) + } +} diff --git a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt b/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt index 632db0ca..aaefbd1f 100644 --- a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt +++ b/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt @@ -4,6 +4,7 @@ package com.coder.gateway.views import com.coder.gateway.CoderGatewayBundle import com.coder.gateway.CoderGatewayConstants +import com.coder.gateway.CoderRemoteConnectionHandle import com.coder.gateway.icons.CoderIcons import com.coder.gateway.models.RecentWorkspaceConnection import com.coder.gateway.models.WorkspaceAgentModel @@ -215,7 +216,8 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: icon(product.icon) cell(ActionLink(connectionDetails.projectPath!!) { cs.launch { - GatewayUI.getInstance().connect(connectionDetails.toWorkspaceParams()) + CoderRemoteConnectionHandle().connect(connectionDetails.toWorkspaceParams()) + GatewayUI.getInstance().reset() } }) label("").resizableColumn().align(AlignX.FILL) diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt index 33b3906e..1dad09f8 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt @@ -1,6 +1,7 @@ package com.coder.gateway.views.steps import com.coder.gateway.CoderGatewayBundle +import com.coder.gateway.CoderRemoteConnectionHandle import com.coder.gateway.icons.CoderIcons import com.coder.gateway.models.CoderWorkspacesWizardModel import com.coder.gateway.models.WorkspaceAgentModel @@ -338,7 +339,7 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea return false } cs.launch { - GatewayUI.getInstance().connect( + CoderRemoteConnectionHandle().connect( selectedIDE .toWorkspaceParams() .withWorkspaceHostname(CoderCLIManager.getHostName(deploymentURL, selectedWorkspace)) @@ -347,6 +348,7 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea .withConfigDirectory(wizardModel.configDirectory) .withName(selectedWorkspace.name) ) + GatewayUI.getInstance().reset() } return true } From 8d60046194bd46237f897ad6fa3aaac872f3fa27 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 3 Aug 2023 13:41:55 -0800 Subject: [PATCH 02/14] Break out askToken dialog So it can be reused in the link flow. --- .../gateway/CoderRemoteConnectionHandle.kt | 102 +++++++++++++++++- .../views/steps/CoderWorkspacesStepView.kt | 93 +--------------- 2 files changed, 102 insertions(+), 93 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt index bf1dc02b..7d95bccb 100644 --- a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt +++ b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt @@ -2,16 +2,30 @@ package com.coder.gateway +import com.coder.gateway.models.TokenSource +import com.coder.gateway.sdk.CoderCLIManager import com.coder.gateway.sdk.humanizeDuration import com.coder.gateway.sdk.isCancellation import com.coder.gateway.sdk.isWorkerTimeout import com.coder.gateway.sdk.suspendingRetryWithExponentialBackOff +import com.coder.gateway.sdk.toURL +import com.coder.gateway.sdk.withPath import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService +import com.intellij.ide.BrowserUtil import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.rd.util.launchUnderBackgroundProgress import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.panel.ComponentPanelBuilder +import com.intellij.ui.AppIcon +import com.intellij.ui.components.JBTextField +import com.intellij.ui.components.dialog +import com.intellij.ui.dsl.builder.RowLayout +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.applyIf +import com.intellij.util.ui.UIUtil import com.jetbrains.gateway.ssh.SshDeployFlowUtil import com.jetbrains.gateway.ssh.SshMultistagePanelContext import com.jetbrains.gateway.ssh.deploy.DeployException @@ -19,6 +33,8 @@ import com.jetbrains.rd.util.lifetime.LifetimeDefinition import kotlinx.coroutines.launch import net.schmizz.sshj.common.SSHException import net.schmizz.sshj.connection.ConnectionException +import java.awt.Dimension +import java.net.URL import java.time.Duration import java.util.concurrent.TimeoutException @@ -31,8 +47,6 @@ class CoderRemoteConnectionHandle { suspend fun connect(parameters: Map) { logger.debug("Creating connection handle", parameters) val clientLifetime = LifetimeDefinition() - // TODO: If this fails determine if it is an auth error and if so prompt - // for a new token, configure the CLI, then try again. clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title"), canBeCancelled = true, isIndeterminate = true, project = null) { try { indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting") @@ -88,5 +102,89 @@ class CoderRemoteConnectionHandle { companion object { val logger = Logger.getInstance(CoderRemoteConnectionHandle::class.java.simpleName) + + /** + * Open a dialog for providing the token. Show any existing token so the + * user can validate it if a previous connection failed. If we are not + * retrying and the user has not checked the existing token box then open a + * browser to the auth page. If the user has checked the existing token box + * then populate the dialog with the token on disk (this will overwrite any + * other existing token) unless this is a retry to avoid clobbering the + * token that just failed. Return the token submitted by the user. + */ + @JvmStatic + fun askToken( + url: URL, + token: Pair?, + isRetry: Boolean, + useExisting: Boolean, + ): Pair? { + var (existingToken, tokenSource) = token ?: Pair("", TokenSource.USER) + val getTokenUrl = url.withPath("/login?redirect=%2Fcli-auth") + if (!isRetry && !useExisting) { + BrowserUtil.browse(getTokenUrl) + } else if (!isRetry && useExisting) { + val (u, t) = CoderCLIManager.readConfig() + if (url == u?.toURL() && !t.isNullOrBlank() && t != existingToken) { + logger.info("Injecting token from CLI config") + tokenSource = TokenSource.CONFIG + existingToken = t + } + } + var tokenFromUser: String? = null + ApplicationManager.getApplication().invokeAndWait({ + lateinit var sessionTokenTextField: JBTextField + val panel = panel { + row { + browserLink( + CoderGatewayBundle.message("gateway.connector.view.login.token.label"), + getTokenUrl.toString() + ) + sessionTokenTextField = textField() + .applyToComponent { + text = existingToken + minimumSize = Dimension(520, -1) + }.component + }.layout(RowLayout.PARENT_GRID) + row { + cell() // To align with the text box. + cell( + ComponentPanelBuilder.createCommentComponent( + CoderGatewayBundle.message( + if (isRetry) "gateway.connector.view.workspaces.token.rejected" + else if (tokenSource == TokenSource.CONFIG) "gateway.connector.view.workspaces.token.injected" + else if (existingToken.isNotBlank()) "gateway.connector.view.workspaces.token.comment" + else "gateway.connector.view.workspaces.token.none" + ), + false, + -1, + true + ).applyIf(isRetry) { + apply { + foreground = UIUtil.getErrorForeground() + } + } + ) + }.layout(RowLayout.PARENT_GRID) + } + AppIcon.getInstance().requestAttention(null, true) + if (!dialog( + CoderGatewayBundle.message("gateway.connector.view.login.token.dialog"), + panel = panel, + focusedComponent = sessionTokenTextField + ).showAndGet() + ) { + return@invokeAndWait + } + tokenFromUser = sessionTokenTextField.text + }, ModalityState.any()) + if (tokenFromUser.isNullOrBlank()) { + return null + } + if (tokenFromUser != existingToken) { + tokenSource = TokenSource.USER + } + return Pair(tokenFromUser!!, tokenSource) + } } } 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 b1d1e59b..f7463a7a 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -1,6 +1,7 @@ package com.coder.gateway.views.steps import com.coder.gateway.CoderGatewayBundle +import com.coder.gateway.CoderRemoteConnectionHandle import com.coder.gateway.icons.CoderIcons import com.coder.gateway.models.CoderWorkspacesWizardModel import com.coder.gateway.models.TokenSource @@ -20,7 +21,6 @@ import com.coder.gateway.sdk.ex.WorkspaceResponseException import com.coder.gateway.sdk.toURL import com.coder.gateway.sdk.v2.models.WorkspaceStatus import com.coder.gateway.sdk.v2.models.toAgentModels -import com.coder.gateway.sdk.withPath import com.coder.gateway.services.CoderSettingsState import com.intellij.icons.AllIcons import com.intellij.ide.ActivityTracker @@ -30,8 +30,6 @@ import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.rd.util.launchUnderBackgroundProgress @@ -39,11 +37,8 @@ import com.intellij.openapi.ui.panel.ComponentPanelBuilder import com.intellij.openapi.ui.setEmptyState import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager import com.intellij.ui.AnActionButton -import com.intellij.ui.AppIcon import com.intellij.ui.RelativeFont import com.intellij.ui.ToolbarDecorator -import com.intellij.ui.components.JBTextField -import com.intellij.ui.components.dialog import com.intellij.ui.dsl.builder.AlignX import com.intellij.ui.dsl.builder.AlignY import com.intellij.ui.dsl.builder.BottomGap @@ -54,7 +49,6 @@ import com.intellij.ui.dsl.builder.bindSelected import com.intellij.ui.dsl.builder.bindText import com.intellij.ui.dsl.builder.panel import com.intellij.ui.table.TableView -import com.intellij.util.applyIf import com.intellij.util.ui.ColumnInfo import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI @@ -391,7 +385,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod val oldURL = localWizardModel.coderURL.toURL() component.apply() // Force bindings to be filled. val newURL = localWizardModel.coderURL.toURL() - val pastedToken = askToken( + val pastedToken = CoderRemoteConnectionHandle.askToken( newURL, // If this is a new URL there is no point in trying to use the same // token. @@ -512,89 +506,6 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } } - /** - * Open a dialog for providing the token. Show any existing token so the - * user can validate it if a previous connection failed. If we are not - * retrying and the user has not checked the existing token box then open a - * browser to the auth page. If the user has checked the existing token box - * then populate the dialog with the token on disk (this will overwrite any - * other existing token) unless this is a retry to avoid clobbering the - * token that just failed. Return the token submitted by the user. - */ - private fun askToken( - url: URL, - token: Pair?, - isRetry: Boolean, - useExisting: Boolean, - ): Pair? { - var (existingToken, tokenSource) = token ?: Pair("", TokenSource.USER) - val getTokenUrl = url.withPath("/login?redirect=%2Fcli-auth") - if (!isRetry && !useExisting) { - BrowserUtil.browse(getTokenUrl) - } else if (!isRetry && useExisting) { - val (u, t) = CoderCLIManager.readConfig() - if (url == u?.toURL() && !t.isNullOrBlank() && t != existingToken) { - logger.info("Injecting token from CLI config") - tokenSource = TokenSource.CONFIG - existingToken = t - } - } - var tokenFromUser: String? = null - ApplicationManager.getApplication().invokeAndWait({ - lateinit var sessionTokenTextField: JBTextField - val panel = panel { - row { - browserLink( - CoderGatewayBundle.message("gateway.connector.view.login.token.label"), - getTokenUrl.toString() - ) - sessionTokenTextField = textField() - .applyToComponent { - text = existingToken - minimumSize = Dimension(520, -1) - }.component - }.layout(RowLayout.PARENT_GRID) - row { - cell() // To align with the text box. - cell( - ComponentPanelBuilder.createCommentComponent( - CoderGatewayBundle.message( - if (isRetry) "gateway.connector.view.workspaces.token.rejected" - else if (tokenSource == TokenSource.CONFIG) "gateway.connector.view.workspaces.token.injected" - else if (existingToken.isNotBlank()) "gateway.connector.view.workspaces.token.comment" - else "gateway.connector.view.workspaces.token.none" - ), - false, - -1, - true - ).applyIf(isRetry) { - apply { - foreground = UIUtil.getErrorForeground() - } - } - ) - }.layout(RowLayout.PARENT_GRID) - } - AppIcon.getInstance().requestAttention(null, true) - if (!dialog( - CoderGatewayBundle.message("gateway.connector.view.login.token.dialog"), - panel = panel, - focusedComponent = sessionTokenTextField - ).showAndGet() - ) { - return@invokeAndWait - } - tokenFromUser = sessionTokenTextField.text - }, ModalityState.any()) - if (tokenFromUser.isNullOrBlank()) { - return null - } - if (tokenFromUser != existingToken) { - tokenSource = TokenSource.USER - } - return Pair(tokenFromUser!!, tokenSource) - } - private fun triggerWorkspacePolling(fetchNow: Boolean) { poller?.cancel() From 77787a8b4e5af0e9cb75c73072e32a3ccb9afe43 Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 7 Aug 2023 12:51:15 -0800 Subject: [PATCH 03/14] Add generic function to ask for input --- .../gateway/CoderRemoteConnectionHandle.kt | 106 ++++++++++-------- 1 file changed, 58 insertions(+), 48 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt index 7d95bccb..b6b4f6ce 100644 --- a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt +++ b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt @@ -103,6 +103,49 @@ class CoderRemoteConnectionHandle { companion object { val logger = Logger.getInstance(CoderRemoteConnectionHandle::class.java.simpleName) + /** + * Generic function to ask for input. + */ + @JvmStatic + fun ask(comment: String, isError: Boolean, link: Pair?, default: String?): String? { + var inputFromUser: String? = null + ApplicationManager.getApplication().invokeAndWait({ + lateinit var inputTextField: JBTextField + val panel = panel { + row { + if (link != null) browserLink(link.first, link.second) + inputTextField = textField() + .applyToComponent { + text = default ?: "" + minimumSize = Dimension(520, -1) + }.component + }.layout(RowLayout.PARENT_GRID) + row { + cell() // To align with the text box. + cell( + ComponentPanelBuilder.createCommentComponent(comment, false, -1, true) + .applyIf(isError) { + apply { + foreground = UIUtil.getErrorForeground() + } + } + ) + }.layout(RowLayout.PARENT_GRID) + } + AppIcon.getInstance().requestAttention(null, true) + if (!dialog( + CoderGatewayBundle.message("gateway.connector.view.login.token.dialog"), + panel = panel, + focusedComponent = inputTextField + ).showAndGet() + ) { + return@invokeAndWait + } + inputFromUser = inputTextField.text + }, ModalityState.any()) + return inputFromUser + } + /** * Open a dialog for providing the token. Show any existing token so the * user can validate it if a previous connection failed. If we are not @@ -131,60 +174,27 @@ class CoderRemoteConnectionHandle { existingToken = t } } - var tokenFromUser: String? = null - ApplicationManager.getApplication().invokeAndWait({ - lateinit var sessionTokenTextField: JBTextField - val panel = panel { - row { - browserLink( - CoderGatewayBundle.message("gateway.connector.view.login.token.label"), - getTokenUrl.toString() - ) - sessionTokenTextField = textField() - .applyToComponent { - text = existingToken - minimumSize = Dimension(520, -1) - }.component - }.layout(RowLayout.PARENT_GRID) - row { - cell() // To align with the text box. - cell( - ComponentPanelBuilder.createCommentComponent( - CoderGatewayBundle.message( - if (isRetry) "gateway.connector.view.workspaces.token.rejected" - else if (tokenSource == TokenSource.CONFIG) "gateway.connector.view.workspaces.token.injected" - else if (existingToken.isNotBlank()) "gateway.connector.view.workspaces.token.comment" - else "gateway.connector.view.workspaces.token.none" - ), - false, - -1, - true - ).applyIf(isRetry) { - apply { - foreground = UIUtil.getErrorForeground() - } - } - ) - }.layout(RowLayout.PARENT_GRID) - } - AppIcon.getInstance().requestAttention(null, true) - if (!dialog( - CoderGatewayBundle.message("gateway.connector.view.login.token.dialog"), - panel = panel, - focusedComponent = sessionTokenTextField - ).showAndGet() - ) { - return@invokeAndWait - } - tokenFromUser = sessionTokenTextField.text - }, ModalityState.any()) + val tokenFromUser = ask( + CoderGatewayBundle.message( + if (isRetry) "gateway.connector.view.workspaces.token.rejected" + else if (tokenSource == TokenSource.CONFIG) "gateway.connector.view.workspaces.token.injected" + else if (existingToken.isNotBlank()) "gateway.connector.view.workspaces.token.comment" + else "gateway.connector.view.workspaces.token.none" + ), + isRetry, + Pair( + CoderGatewayBundle.message("gateway.connector.view.login.token.label"), + getTokenUrl.toString() + ), + existingToken, + ) if (tokenFromUser.isNullOrBlank()) { return null } if (tokenFromUser != existingToken) { tokenSource = TokenSource.USER } - return Pair(tokenFromUser!!, tokenSource) + return Pair(tokenFromUser, tokenSource) } } } From e20d5032c6a5c9732324a05b3dce4e650f3079d9 Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 7 Aug 2023 13:45:30 -0800 Subject: [PATCH 04/14] Add QUERY token source and show deployment host When you connect via a Gateway link it might not be obvious if the deployment URL is wrong. --- .../com/coder/gateway/CoderRemoteConnectionHandle.kt | 8 +++++--- .../coder/gateway/models/CoderWorkspacesWizardModel.kt | 1 + .../resources/messages/CoderGatewayBundle.properties | 9 +++++---- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt index b6b4f6ce..3ed22ba9 100644 --- a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt +++ b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt @@ -107,7 +107,7 @@ class CoderRemoteConnectionHandle { * Generic function to ask for input. */ @JvmStatic - fun ask(comment: String, isError: Boolean, link: Pair?, default: String?): String? { + fun ask(comment: String, isError: Boolean = false, link: Pair? = null, default: String? = null): String? { var inputFromUser: String? = null ApplicationManager.getApplication().invokeAndWait({ lateinit var inputTextField: JBTextField @@ -169,7 +169,7 @@ class CoderRemoteConnectionHandle { } else if (!isRetry && useExisting) { val (u, t) = CoderCLIManager.readConfig() if (url == u?.toURL() && !t.isNullOrBlank() && t != existingToken) { - logger.info("Injecting token from CLI config") + logger.info("Injecting token for $url from CLI config") tokenSource = TokenSource.CONFIG existingToken = t } @@ -178,8 +178,10 @@ class CoderRemoteConnectionHandle { CoderGatewayBundle.message( if (isRetry) "gateway.connector.view.workspaces.token.rejected" else if (tokenSource == TokenSource.CONFIG) "gateway.connector.view.workspaces.token.injected" + else if (tokenSource == TokenSource.QUERY) "gateway.connector.view.workspaces.token.query" else if (existingToken.isNotBlank()) "gateway.connector.view.workspaces.token.comment" - else "gateway.connector.view.workspaces.token.none" + else "gateway.connector.view.workspaces.token.none", + url.host, ), isRetry, Pair( diff --git a/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt b/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt index b2a8f9fb..8be9a361 100644 --- a/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt +++ b/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt @@ -3,6 +3,7 @@ package com.coder.gateway.models enum class TokenSource { CONFIG, // Pulled from the Coder CLI config. USER, // Input by the user. + QUERY, // From the Gateway link as a query parameter. LAST_USED, // Last used token, either from storage or current run. } diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index b2ac2026..b0da2570 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -36,10 +36,11 @@ gateway.connector.view.workspaces.connect.download-failed=Failed to download Cod gateway.connector.view.workspaces.connect.ssl-error=Connection to {0} failed: {1}. See the \ documentation for TLS certificates \ for information on how to make your system trust certificates coming from your deployment. -gateway.connector.view.workspaces.token.comment=The last used token is shown above. -gateway.connector.view.workspaces.token.rejected=This token was rejected. -gateway.connector.view.workspaces.token.injected=This token was pulled from your CLI config. -gateway.connector.view.workspaces.token.none=No existing token found. +gateway.connector.view.workspaces.token.comment=The last used token for {0} is shown above. +gateway.connector.view.workspaces.token.rejected=This token was rejected by {0}. +gateway.connector.view.workspaces.token.injected=This token was pulled from your CLI config for {0}. +gateway.connector.view.workspaces.token.query=This token was pulled from the Gateway link from {0}. +gateway.connector.view.workspaces.token.none=No existing token for {0} found. gateway.connector.view.coder.connect-ssh=Establishing SSH connection to remote worker... gateway.connector.view.coder.connect-ssh.retry=Establishing SSH connection to remote worker (attempt {0})... gateway.connector.view.coder.retrieve-ides=Retrieving IDEs... From 78a8df4f4af555157ccd1a76509595f17b1adcde Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 7 Aug 2023 13:46:14 -0800 Subject: [PATCH 05/14] Tweak client error responses --- .../gateway/sdk/CoderRestClientService.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt index 244ebba8..f6488562 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt @@ -50,7 +50,7 @@ class CoderRestClientService { } } -class CoderRestClient(var url: URL, private var token: String) { +class CoderRestClient(var url: URL, var token: String) { private var httpClient: OkHttpClient private var retroRestClient: CoderV2RestFacade @@ -75,7 +75,7 @@ class CoderRestClient(var url: URL, private var token: String) { fun me(): User { val userResponse = retroRestClient.me().execute() if (!userResponse.isSuccessful) { - throw AuthenticationResponseException("Could not retrieve information about logged user:${userResponse.code()}, reason: ${userResponse.message().ifBlank { "no reason provided" }}") + throw AuthenticationResponseException("Authentication to $url failed with code ${userResponse.code()}, ${userResponse.message().ifBlank { "has your token expired?" }}") } return userResponse.body()!! @@ -88,7 +88,7 @@ class CoderRestClient(var url: URL, private var token: String) { fun workspaces(): List { val workspacesResponse = retroRestClient.workspaces("owner:me").execute() if (!workspacesResponse.isSuccessful) { - throw WorkspaceResponseException("Could not retrieve Coder Workspaces:${workspacesResponse.code()}, reason: ${workspacesResponse.message().ifBlank { "no reason provided" }}") + throw WorkspaceResponseException("Retrieving workspaces from $url failed with code ${workspacesResponse.code()}, reason: ${workspacesResponse.message().ifBlank { "no reason provided" }}") } return workspacesResponse.body()!!.workspaces @@ -97,15 +97,15 @@ class CoderRestClient(var url: URL, private var token: String) { fun buildInfo(): BuildInfo { val buildInfoResponse = retroRestClient.buildInfo().execute() if (!buildInfoResponse.isSuccessful) { - throw java.lang.IllegalStateException("Could not retrieve build information for Coder instance $url, reason:${buildInfoResponse.message().ifBlank { "no reason provided" }}") + throw java.lang.IllegalStateException("Retrieving build information for $url failed with code ${buildInfoResponse.code()}, reason:${buildInfoResponse.message().ifBlank { "no reason provided" }}") } return buildInfoResponse.body()!! } - fun template(templateID: UUID): Template { + private fun template(templateID: UUID): Template { val templateResponse = retroRestClient.template(templateID).execute() if (!templateResponse.isSuccessful) { - throw TemplateResponseException("Failed to retrieve template with id: $templateID, reason: ${templateResponse.message().ifBlank { "no reason provided" }}") + throw TemplateResponseException("Retrieving template with id $templateID from $url failed with code ${templateResponse.code()}, reason: ${templateResponse.message().ifBlank { "no reason provided" }}") } return templateResponse.body()!! } @@ -114,7 +114,7 @@ class CoderRestClient(var url: URL, private var token: String) { val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.START, null, null, null, null) val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute() if (buildResponse.code() != HTTP_CREATED) { - throw WorkspaceResponseException("Failed to build workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") + throw WorkspaceResponseException("Building workspace $workspaceName on $url failed with code ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") } return buildResponse.body()!! @@ -124,7 +124,7 @@ class CoderRestClient(var url: URL, private var token: String) { val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP, null, null, null, null) val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute() if (buildResponse.code() != HTTP_CREATED) { - throw WorkspaceResponseException("Failed to stop workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") + throw WorkspaceResponseException("Stopping workspace $workspaceName on $url failed with code ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") } return buildResponse.body()!! @@ -136,7 +136,7 @@ class CoderRestClient(var url: URL, private var token: String) { val buildRequest = CreateWorkspaceBuildRequest(template.activeVersionID, lastWorkspaceTransition, null, null, null, null) val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute() if (buildResponse.code() != HTTP_CREATED) { - throw WorkspaceResponseException("Failed to update workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") + throw WorkspaceResponseException("Updating workspace $workspaceName on $url failed with code ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") } return buildResponse.body()!! From 5569d46a69e8845dad5dc9d4e118f10ab2078c3f Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 7 Aug 2023 14:02:53 -0800 Subject: [PATCH 06/14] Use exception class name when there is no message Some exceptions have no message, like null pointer exceptions. Showing the class name seems more helpful than "no details", since it can save you a trip to the logs. --- .../com/coder/gateway/CoderRemoteConnectionHandle.kt | 8 ++++---- .../views/steps/CoderLocateRemoteProjectStepView.kt | 6 +++--- src/main/resources/messages/CoderGatewayBundle.properties | 1 - 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt index 3ed22ba9..156af956 100644 --- a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt +++ b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt @@ -68,7 +68,7 @@ class CoderRemoteConnectionHandle { // indicator.text2 is the text below the progress bar. indicator.text2 = if (isWorkerTimeout(e)) "Failed to upload worker binary...it may have timed out" - else e.message ?: CoderGatewayBundle.message("gateway.connector.no-details") + else e.message ?: e.javaClass.simpleName }, onCountdown = { remainingMs -> indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting.failed.retry", humanizeDuration(remainingMs)) @@ -84,14 +84,14 @@ class CoderRemoteConnectionHandle { recentConnectionsService.addRecentConnection(parameters.toRecentWorkspaceConnection()) } catch (e: Exception) { if (isCancellation(e)) { - logger.info("Connection canceled due to ${e.javaClass}") + logger.info("Connection canceled due to ${e.javaClass.simpleName}") } else { - logger.info("Failed to connect (will not retry)", e) + 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 ?: CoderGatewayBundle.message("gateway.connector.no-details"), + e.message ?: e.javaClass.simpleName, CoderGatewayBundle.message("gateway.connector.coder.connection.failed"), Messages.getErrorIcon()) } diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt index 1dad09f8..5e477d1b 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt @@ -213,7 +213,7 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea cbIDEComment.foreground = UIUtil.getErrorForeground() cbIDEComment.text = if (isWorkerTimeout(e)) "Failed to upload worker binary...it may have timed out. Check the command log for more details." - else e.message ?: CoderGatewayBundle.message("gateway.connector.no-details") + else e.message ?: e.javaClass.simpleName }, onCountdown = { remainingMs -> cbIDE.renderer = IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.retrieve-ides.failed.retry", humanizeDuration(remainingMs))) @@ -225,11 +225,11 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea } } catch (e: Exception) { if (isCancellation(e)) { - logger.info("Connection canceled due to ${e.javaClass}") + logger.info("Connection canceled due to ${e.javaClass.simpleName}") } else { logger.error("Failed to retrieve IDEs (will not retry)", e) cbIDEComment.foreground = UIUtil.getErrorForeground() - cbIDEComment.text = e.message ?: CoderGatewayBundle.message("gateway.connector.no-details") + cbIDEComment.text = e.message ?: e.javaClass.simpleName cbIDE.renderer = IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.retrieve-ides.failed"), UIUtil.getBalloonErrorIcon()) } } diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index b0da2570..68fd4b50 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -87,4 +87,3 @@ gateway.connector.settings.enable-binary-directory-fallback.title=Fall back to d gateway.connector.settings.enable-binary-directory-fallback.comment=Checking this \ box will allow the plugin to fall back to the data directory when the CLI \ directory is not writable. -gateway.connector.no-details="The error did not provide any further details" From 809f5b2d9ab313ceb553895353bf921c26e0dc57 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 3 Aug 2023 12:53:06 -0800 Subject: [PATCH 07/14] Handle Gateway links --- README.md | 10 ++ .../gateway/CoderGatewayConnectionProvider.kt | 115 +++++++++++++++++- .../gateway/CoderRemoteConnectionHandle.kt | 6 +- .../com/coder/gateway/WorkspaceParams.kt | 2 +- ...erGatewayRecentWorkspaceConnectionsView.kt | 2 +- .../steps/CoderLocateRemoteProjectStepView.kt | 4 +- 6 files changed, 131 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e7bc6ea9..bc2b93b7 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,16 @@ To manually install a local build: Alternatively, `./gradlew clean runIde` will deploy a Gateway distribution (the one specified in `gradle.properties` - `platformVersion`) with the latest plugin changes deployed. +To simulate opening a workspace from the dashboard pass the Gateway link via `--args`. For example: + +``` +./gradlew clean runIDE --args="jetbrains-gateway://connect#type=coder&workspace=dev&agent=coder&folder=/home/coder&url=https://dev.coder.com&token=&ide_product_code=IU&ide_build_number=223.8836.41&ide_download_link=https://download.jetbrains.com/idea/ideaIU-2022.3.3.tar.gz" +``` + +Alternatively, if you have separately built the plugin and already installed it +in a Gateway distribution you can launch that distribution with the URL as the +first argument (no `--args` in this case). + ### Plugin Structure ``` diff --git a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt index 90a354f5..5961340d 100644 --- a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt +++ b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt @@ -2,18 +2,129 @@ package com.coder.gateway +import com.coder.gateway.models.TokenSource +import com.coder.gateway.sdk.CoderCLIManager +import com.coder.gateway.sdk.CoderRestClient +import com.coder.gateway.sdk.ex.AuthenticationResponseException +import com.coder.gateway.sdk.toURL +import com.coder.gateway.sdk.v2.models.toAgentModels +import com.coder.gateway.sdk.withPath +import com.coder.gateway.services.CoderSettingsState +import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.Logger import com.jetbrains.gateway.api.ConnectionRequestor import com.jetbrains.gateway.api.GatewayConnectionHandle import com.jetbrains.gateway.api.GatewayConnectionProvider +import java.net.URL +// In addition to `type`, these are the keys that we support in our Gateway +// links. +private const val URL = "url" +private const val TOKEN = "token" +private const val WORKSPACE = "workspace" +private const val AGENT = "agent" +private const val FOLDER = "folder" +private const val IDE_DOWNLOAD_LINK = "ide_download_link" +private const val IDE_PRODUCT_CODE = "ide_product_code" +private const val IDE_BUILD_NUMBER = "ide_build_number" +private const val IDE_PATH_ON_HOST = "ide_path_on_host" + +// CoderGatewayConnectionProvider handles connecting via a Gateway link such as +// jetbrains-gateway://connect#type=coder. class CoderGatewayConnectionProvider : GatewayConnectionProvider { + private val settings: CoderSettingsState = service() + override suspend fun connect(parameters: Map, requestor: ConnectionRequestor): GatewayConnectionHandle? { - logger.debug("Launched Coder connection provider", parameters) - CoderRemoteConnectionHandle().connect(parameters) + CoderRemoteConnectionHandle().connect{ indicator -> + logger.debug("Launched Coder connection provider", parameters) + + val deploymentURL = parameters[URL] + ?: CoderRemoteConnectionHandle.ask("Enter the full URL of your Coder deployment") + if (deploymentURL.isNullOrBlank()) { + throw IllegalArgumentException("Query parameter \"$URL\" is missing") + } + + val (client, username) = authenticate(deploymentURL.toURL(), parameters[TOKEN]) + + // TODO: If these are missing we could launch the wizard. + val name = parameters[WORKSPACE] ?: throw IllegalArgumentException("Query parameter \"$WORKSPACE\" is missing") + val agent = parameters[AGENT] ?: throw IllegalArgumentException("Query parameter \"$AGENT\" is missing") + + val workspaces = client.workspaces() + val agents = workspaces.flatMap { it.toAgentModels() } + val workspace = agents.firstOrNull { it.name == "$name.$agent" } + ?: throw IllegalArgumentException("The agent $agent does not exist on the workspace $name or the workspace is off") + + // TODO: Turn on the workspace if it is off then wait for the agent + // to be ready. Also, distinguish between whether the + // workspace is off or the agent does not exist in the error + // above instead of showing a combined error. + + val cli = CoderCLIManager.ensureCLI( + deploymentURL.toURL(), + client.buildInfo().version, + settings, + indicator, + ) + + indicator.text = "Authenticating Coder CLI..." + cli.login(client.token) + + indicator.text = "Configuring Coder CLI..." + cli.configSsh(agents) + + // TODO: Ask for these if missing. Maybe we can reuse the second + // step of the wizard? Could also be nice if we automatically used + // the last IDE. + if (parameters[IDE_PRODUCT_CODE].isNullOrBlank()) { + throw IllegalArgumentException("Query parameter \"$IDE_PRODUCT_CODE\" is missing") + } + if (parameters[IDE_BUILD_NUMBER].isNullOrBlank()) { + throw IllegalArgumentException("Query parameter \"$IDE_BUILD_NUMBER\" is missing") + } + if (parameters[IDE_PATH_ON_HOST].isNullOrBlank() && parameters[IDE_DOWNLOAD_LINK].isNullOrBlank()) { + throw IllegalArgumentException("One of \"$IDE_PATH_ON_HOST\" or \"$IDE_DOWNLOAD_LINK\" is required") + } + + // TODO: Ask for the project path if missing and validate the path. + val folder = parameters[FOLDER] ?: throw IllegalArgumentException("Query parameter \"$FOLDER\" is missing") + + parameters + .withWorkspaceHostname(CoderCLIManager.getHostName(deploymentURL.toURL(), workspace)) + .withProjectPath(folder) + .withWebTerminalLink(client.url.withPath("/@$username/$workspace.name/terminal").toString()) + .withConfigDirectory(cli.coderConfigPath.toString()) + .withName(name) + } return null } + /** + * Return an authenticated Coder CLI and the user's name, asking for the + * token as long as it continues to result in an authentication failure. + */ + private fun authenticate(deploymentURL: URL, queryToken: String?, lastToken: Pair? = null): Pair { + // Use the token from the query, unless we already tried that. + val isRetry = lastToken != null + val token = if (!queryToken.isNullOrBlank() && !isRetry) + Pair(queryToken, TokenSource.QUERY) + else CoderRemoteConnectionHandle.askToken( + deploymentURL, + lastToken, + isRetry, + useExisting = true, + ) + if (token == null) { // User aborted. + throw IllegalArgumentException("Unable to connect to $deploymentURL, $TOKEN is missing") + } + val client = CoderRestClient(deploymentURL, token.first) + return try { + Pair(client, client.me().username) + } catch (ex: AuthenticationResponseException) { + authenticate(deploymentURL, queryToken, token) + } + } + override fun isApplicable(parameters: Map): Boolean { return parameters.areCoderType() } diff --git a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt index 156af956..20fd66f6 100644 --- a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt +++ b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt @@ -16,6 +16,7 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.rd.util.launchUnderBackgroundProgress import com.intellij.openapi.ui.Messages import com.intellij.openapi.ui.panel.ComponentPanelBuilder @@ -44,11 +45,12 @@ import java.util.concurrent.TimeoutException class CoderRemoteConnectionHandle { private val recentConnectionsService = service() - suspend fun connect(parameters: Map) { - logger.debug("Creating connection handle", parameters) + suspend fun connect(getParameters: (indicator: ProgressIndicator) -> Map) { val clientLifetime = LifetimeDefinition() clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title"), canBeCancelled = true, isIndeterminate = true, project = null) { try { + val parameters = getParameters(indicator) + logger.debug("Creating connection handle", parameters) indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting") val context = suspendingRetryWithExponentialBackOff( action = { attempt -> diff --git a/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt b/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt index a30c2b76..02c9bddb 100644 --- a/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt +++ b/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt @@ -98,7 +98,7 @@ fun Map.withName(name: String): Map { fun Map.areCoderType(): Boolean { - return this[TYPE] == VALUE_FOR_TYPE && !this[CODER_WORKSPACE_HOSTNAME].isNullOrBlank() && !this[PROJECT_PATH].isNullOrBlank() + return this[TYPE] == VALUE_FOR_TYPE } fun Map.toSshConfig(): SshConfig { diff --git a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt b/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt index aaefbd1f..04eae3f0 100644 --- a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt +++ b/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt @@ -216,7 +216,7 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: icon(product.icon) cell(ActionLink(connectionDetails.projectPath!!) { cs.launch { - CoderRemoteConnectionHandle().connect(connectionDetails.toWorkspaceParams()) + CoderRemoteConnectionHandle().connect{ connectionDetails.toWorkspaceParams() } GatewayUI.getInstance().reset() } }) diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt index 5e477d1b..5353122a 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt @@ -339,7 +339,7 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea return false } cs.launch { - CoderRemoteConnectionHandle().connect( + CoderRemoteConnectionHandle().connect{ selectedIDE .toWorkspaceParams() .withWorkspaceHostname(CoderCLIManager.getHostName(deploymentURL, selectedWorkspace)) @@ -347,7 +347,7 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea .withWebTerminalLink("${terminalLink.url}") .withConfigDirectory(wizardModel.configDirectory) .withName(selectedWorkspace.name) - ) + } GatewayUI.getInstance().reset() } return true From 153a48ef31aad58d20a748720bcbc16ba155a187 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 16 Aug 2023 14:17:47 -0800 Subject: [PATCH 08/14] Fix If-None-Match header name --- src/main/resources/messages/CoderGatewayBundle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index 68fd4b50..bf78096a 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -70,7 +70,7 @@ gateway.connector.settings.data-directory.comment=Directories are created \ Defaults to {0}. gateway.connector.settings.binary-source.title=CLI source: gateway.connector.settings.binary-source.comment=Used to download the Coder \ - CLI which is necessary to make SSH connections. The If-None-Matched header \ + CLI which is necessary to make SSH connections. The If-None-Match header \ will be set to the SHA1 of the CLI and can be used for caching. Absolute \ URLs will be used as-is; otherwise this value will be resolved against the \ deployment domain. \ From b654ace18013dfb63c9a11c2db249dde22ad64f0 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 16 Aug 2023 14:17:55 -0800 Subject: [PATCH 09/14] Use active voice --- .../coder/gateway/sdk/CoderRestClientService.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt index f6488562..4c4e4c9a 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt @@ -75,7 +75,7 @@ class CoderRestClient(var url: URL, var token: String) { fun me(): User { val userResponse = retroRestClient.me().execute() if (!userResponse.isSuccessful) { - throw AuthenticationResponseException("Authentication to $url failed with code ${userResponse.code()}, ${userResponse.message().ifBlank { "has your token expired?" }}") + throw AuthenticationResponseException("Unable to authenticate to $url: code ${userResponse.code()}, ${userResponse.message().ifBlank { "has your token expired?" }}") } return userResponse.body()!! @@ -88,7 +88,7 @@ class CoderRestClient(var url: URL, var token: String) { fun workspaces(): List { val workspacesResponse = retroRestClient.workspaces("owner:me").execute() if (!workspacesResponse.isSuccessful) { - throw WorkspaceResponseException("Retrieving workspaces from $url failed with code ${workspacesResponse.code()}, reason: ${workspacesResponse.message().ifBlank { "no reason provided" }}") + throw WorkspaceResponseException("Unable to retrieve workspaces from $url: code ${workspacesResponse.code()}, reason: ${workspacesResponse.message().ifBlank { "no reason provided" }}") } return workspacesResponse.body()!!.workspaces @@ -97,7 +97,7 @@ class CoderRestClient(var url: URL, var token: String) { fun buildInfo(): BuildInfo { val buildInfoResponse = retroRestClient.buildInfo().execute() if (!buildInfoResponse.isSuccessful) { - throw java.lang.IllegalStateException("Retrieving build information for $url failed with code ${buildInfoResponse.code()}, reason:${buildInfoResponse.message().ifBlank { "no reason provided" }}") + throw java.lang.IllegalStateException("Unable to retrieve build information for $url, code: ${buildInfoResponse.code()}, reason: ${buildInfoResponse.message().ifBlank { "no reason provided" }}") } return buildInfoResponse.body()!! } @@ -105,7 +105,7 @@ class CoderRestClient(var url: URL, var token: String) { private fun template(templateID: UUID): Template { val templateResponse = retroRestClient.template(templateID).execute() if (!templateResponse.isSuccessful) { - throw TemplateResponseException("Retrieving template with id $templateID from $url failed with code ${templateResponse.code()}, reason: ${templateResponse.message().ifBlank { "no reason provided" }}") + throw TemplateResponseException("Unable to retrieve template with ID $templateID from $url, code: ${templateResponse.code()}, reason: ${templateResponse.message().ifBlank { "no reason provided" }}") } return templateResponse.body()!! } @@ -114,7 +114,7 @@ class CoderRestClient(var url: URL, var token: String) { val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.START, null, null, null, null) val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute() if (buildResponse.code() != HTTP_CREATED) { - throw WorkspaceResponseException("Building workspace $workspaceName on $url failed with code ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") + throw WorkspaceResponseException("Unable to build workspace $workspaceName on $url, code: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") } return buildResponse.body()!! @@ -124,7 +124,7 @@ class CoderRestClient(var url: URL, var token: String) { val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP, null, null, null, null) val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute() if (buildResponse.code() != HTTP_CREATED) { - throw WorkspaceResponseException("Stopping workspace $workspaceName on $url failed with code ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") + throw WorkspaceResponseException("Unable to stop workspace $workspaceName on $url, code: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") } return buildResponse.body()!! @@ -136,7 +136,7 @@ class CoderRestClient(var url: URL, var token: String) { val buildRequest = CreateWorkspaceBuildRequest(template.activeVersionID, lastWorkspaceTransition, null, null, null, null) val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute() if (buildResponse.code() != HTTP_CREATED) { - throw WorkspaceResponseException("Updating workspace $workspaceName on $url failed with code ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") + throw WorkspaceResponseException("Unable to update workspace $workspaceName on $url, code: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") } return buildResponse.body()!! From aa453f4bdbf198bd57cf297dc39cd9db4fb3ed3f Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 16 Aug 2023 14:23:23 -0800 Subject: [PATCH 10/14] Automatically use token on disk if there is one Rather than asking the user to confirm. This only happens if we explicitly want to use an existing token anyway, and "existing" is defined in the help text as either the token on disk or one the user already copied, so the extra confirmation to use the token on disk seems unnecessary. --- .../gateway/CoderRemoteConnectionHandle.kt | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt index 20fd66f6..ea896a53 100644 --- a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt +++ b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt @@ -166,16 +166,25 @@ class CoderRemoteConnectionHandle { ): Pair? { var (existingToken, tokenSource) = token ?: Pair("", TokenSource.USER) val getTokenUrl = url.withPath("/login?redirect=%2Fcli-auth") - if (!isRetry && !useExisting) { - BrowserUtil.browse(getTokenUrl) - } else if (!isRetry && useExisting) { - val (u, t) = CoderCLIManager.readConfig() - if (url == u?.toURL() && !t.isNullOrBlank() && t != existingToken) { - logger.info("Injecting token for $url from CLI config") - tokenSource = TokenSource.CONFIG - existingToken = t + + // On the first run either open a browser to generate a new token + // or, if using an existing token, use the token on disk if it + // exists otherwise assume the user already copied an existing + // token and they will paste in. + if (!isRetry) { + if (!useExisting) { + BrowserUtil.browse(getTokenUrl) + } else { + val (u, t) = CoderCLIManager.readConfig() + if (url == u?.toURL() && !t.isNullOrBlank() && t != existingToken) { + logger.info("Injecting token for $url from CLI config") + return Pair(t, TokenSource.CONFIG) + } } } + + // On subsequent tries or if not using an existing token, ask the user + // for the token. val tokenFromUser = ask( CoderGatewayBundle.message( if (isRetry) "gateway.connector.view.workspaces.token.rejected" From 2d27c464c3f50ca49c921642741bd00eb66424ca Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 16 Aug 2023 14:50:12 -0800 Subject: [PATCH 11/14] Use only agent if none provided Also add a few checks against the status. --- .../gateway/CoderGatewayConnectionProvider.kt | 56 ++++++++++++++----- .../gateway/models/WorkspaceAndAgentStatus.kt | 8 +++ 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt index 5961340d..9b386e62 100644 --- a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt +++ b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt @@ -7,6 +7,7 @@ import com.coder.gateway.sdk.CoderCLIManager import com.coder.gateway.sdk.CoderRestClient import com.coder.gateway.sdk.ex.AuthenticationResponseException import com.coder.gateway.sdk.toURL +import com.coder.gateway.sdk.v2.models.WorkspaceStatus import com.coder.gateway.sdk.v2.models.toAgentModels import com.coder.gateway.sdk.withPath import com.coder.gateway.services.CoderSettingsState @@ -46,19 +47,48 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider { val (client, username) = authenticate(deploymentURL.toURL(), parameters[TOKEN]) - // TODO: If these are missing we could launch the wizard. - val name = parameters[WORKSPACE] ?: throw IllegalArgumentException("Query parameter \"$WORKSPACE\" is missing") - val agent = parameters[AGENT] ?: throw IllegalArgumentException("Query parameter \"$AGENT\" is missing") + // TODO: If the workspace is missing we could launch the wizard. + val workspaceName = parameters[WORKSPACE] ?: throw IllegalArgumentException("Query parameter \"$WORKSPACE\" is missing") val workspaces = client.workspaces() - val agents = workspaces.flatMap { it.toAgentModels() } - val workspace = agents.firstOrNull { it.name == "$name.$agent" } - ?: throw IllegalArgumentException("The agent $agent does not exist on the workspace $name or the workspace is off") + val workspace = workspaces.firstOrNull{ it.name == workspaceName } ?: throw IllegalArgumentException("The workspace $workspaceName does not exist") + + when (workspace.latestBuild.status) { + WorkspaceStatus.PENDING, WorkspaceStatus.STARTING -> + // TODO: Wait for the workspace to turn on. + throw IllegalArgumentException("The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; please wait then try again") + WorkspaceStatus.STOPPING, WorkspaceStatus.STOPPED, + WorkspaceStatus.CANCELING, WorkspaceStatus.CANCELED, + WorkspaceStatus.FAILED, -> + // TODO: Turn on the workspace. + throw IllegalArgumentException("The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; please turn on the workspace and try again") + WorkspaceStatus.DELETING, WorkspaceStatus.DELETED, -> + throw IllegalArgumentException("The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; unable to connect") + WorkspaceStatus.RUNNING -> Unit // All is well + } + + val agents = workspace.toAgentModels() + if (agents.isEmpty()) { + throw IllegalArgumentException("The workspace \"$workspaceName\" has no agents") + } + + // If the agent is missing and the workspace has only one, use that. + val agent = if (!parameters[AGENT].isNullOrBlank()) + agents.firstOrNull { it.name == "$workspaceName.${parameters[AGENT]}"} + else if (agents.size == 1) agents.first() + else null - // TODO: Turn on the workspace if it is off then wait for the agent - // to be ready. Also, distinguish between whether the - // workspace is off or the agent does not exist in the error - // above instead of showing a combined error. + if (agent == null) { + // TODO: Show a dropdown and ask for an agent. + throw IllegalArgumentException("Query parameter \"$AGENT\" is missing") + } + + if (agent.agentStatus.pending()) { + // TODO: Wait for the agent to be ready. + throw IllegalArgumentException("The agent \"${agent.name}\" is ${agent.agentStatus.toString().lowercase()}; please wait then try again") + } else if (!agent.agentStatus.ready()) { + throw IllegalArgumentException("The agent \"${agent.name}\" is ${agent.agentStatus.toString().lowercase()}; unable to connect") + } val cli = CoderCLIManager.ensureCLI( deploymentURL.toURL(), @@ -71,7 +101,7 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider { cli.login(client.token) indicator.text = "Configuring Coder CLI..." - cli.configSsh(agents) + cli.configSsh(workspaces.flatMap { it.toAgentModels() }) // TODO: Ask for these if missing. Maybe we can reuse the second // step of the wizard? Could also be nice if we automatically used @@ -90,11 +120,11 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider { val folder = parameters[FOLDER] ?: throw IllegalArgumentException("Query parameter \"$FOLDER\" is missing") parameters - .withWorkspaceHostname(CoderCLIManager.getHostName(deploymentURL.toURL(), workspace)) + .withWorkspaceHostname(CoderCLIManager.getHostName(deploymentURL.toURL(), agent)) .withProjectPath(folder) .withWebTerminalLink(client.url.withPath("/@$username/$workspace.name/terminal").toString()) .withConfigDirectory(cli.coderConfigPath.toString()) - .withName(name) + .withName(workspaceName) } return null } diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt index 1238e147..35a66047 100644 --- a/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt +++ b/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt @@ -57,6 +57,14 @@ enum class WorkspaceAndAgentStatus(val icon: Icon, val label: String, val descri .contains(this) } + /** + * Return true if the agent might soon be in a connectable state. + */ + fun pending(): Boolean { + return listOf(CONNECTING, TIMEOUT, CREATED, AGENT_STARTING, START_TIMEOUT) + .contains(this) + } + // We want to check that the workspace is `running`, the agent is // `connected`, and the agent lifecycle state is `ready` to ensure the best // possible scenario for attempting a connection. From 64fe78fd642ef68be6b02820e1cfe20f669bd997 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 17 Aug 2023 13:27:44 -0800 Subject: [PATCH 12/14] Confirm download link if not whitelisted --- .../gateway/CoderGatewayConnectionProvider.kt | 48 +++++++++- .../gateway/CoderRemoteConnectionHandle.kt | 90 +++++++++++++++++++ .../CoderRemoteConnectionHandleTest.groovy | 72 +++++++++++++++ 3 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 src/test/groovy/CoderRemoteConnectionHandleTest.groovy diff --git a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt index 9b386e62..b1cc1f37 100644 --- a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt +++ b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt @@ -58,11 +58,10 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider { // TODO: Wait for the workspace to turn on. throw IllegalArgumentException("The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; please wait then try again") WorkspaceStatus.STOPPING, WorkspaceStatus.STOPPED, - WorkspaceStatus.CANCELING, WorkspaceStatus.CANCELED, - WorkspaceStatus.FAILED, -> + WorkspaceStatus.CANCELING, WorkspaceStatus.CANCELED -> // TODO: Turn on the workspace. - throw IllegalArgumentException("The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; please turn on the workspace and try again") - WorkspaceStatus.DELETING, WorkspaceStatus.DELETED, -> + throw IllegalArgumentException("The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; please start the workspace and try again") + WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED, -> throw IllegalArgumentException("The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; unable to connect") WorkspaceStatus.RUNNING -> Unit // All is well } @@ -116,6 +115,10 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider { throw IllegalArgumentException("One of \"$IDE_PATH_ON_HOST\" or \"$IDE_DOWNLOAD_LINK\" is required") } + // Check that both the domain and the redirected domain are + // whitelisted. If not, check with the user whether to proceed. + verifyDownloadLink(parameters, deploymentURL.toURL()) + // TODO: Ask for the project path if missing and validate the path. val folder = parameters[FOLDER] ?: throw IllegalArgumentException("Query parameter \"$FOLDER\" is missing") @@ -155,6 +158,43 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider { } } + /** + * Check that the link is whitelisted. If not, confirm with the user. + */ + private fun verifyDownloadLink(parameters: Map, deploymentURL: URL) { + val link = parameters[IDE_DOWNLOAD_LINK] + if (link.isNullOrBlank()) { + return // Nothing to verify + } + + val url = try { + link.toURL() + } catch (ex: Exception) { + throw IllegalArgumentException("$link is not a valid URL") + } + + val (whitelisted, https, linkWithRedirect) = try { + CoderRemoteConnectionHandle.isWhitelisted(url, deploymentURL) + } catch (e: Exception) { + throw IllegalArgumentException("Unable to verify $url: $e") + } + if (whitelisted && https) { + return + } + + val comment = if (whitelisted) "The download link is from a non-whitelisted URL" + else if (https) "The download link is not using HTTPS" + else "The download link is from a non-whitelisted URL and is not using HTTPS" + + if (!CoderRemoteConnectionHandle.confirm( + "Confirm download URL", + "$comment. Would you like to proceed?", + linkWithRedirect, + )) { + throw IllegalArgumentException("$linkWithRedirect is not whitelisted") + } + } + override fun isApplicable(parameters: Map): Boolean { return parameters.areCoderType() } diff --git a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt index ea896a53..c1b2c606 100644 --- a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt +++ b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt @@ -35,9 +35,11 @@ import kotlinx.coroutines.launch import net.schmizz.sshj.common.SSHException import net.schmizz.sshj.connection.ConnectionException import java.awt.Dimension +import java.net.HttpURLConnection import java.net.URL import java.time.Duration import java.util.concurrent.TimeoutException +import javax.net.ssl.SSLHandshakeException // CoderRemoteConnection uses the provided workspace SSH parameters to launch an // IDE against the workspace. If successful the connection is added to recent @@ -105,6 +107,33 @@ class CoderRemoteConnectionHandle { companion object { val logger = Logger.getInstance(CoderRemoteConnectionHandle::class.java.simpleName) + /** + * Generic function to ask for consent. + */ + fun confirm(title: String, comment: String, details: String): Boolean { + var inputFromUser = false + ApplicationManager.getApplication().invokeAndWait({ + val panel = panel { + row { + label(comment) + } + row { + label(details) + } + } + AppIcon.getInstance().requestAttention(null, true) + if (!dialog( + title = title, + panel = panel, + ).showAndGet() + ) { + return@invokeAndWait + } + inputFromUser = true + }, ModalityState.defaultModalityState()) + return inputFromUser + } + /** * Generic function to ask for input. */ @@ -209,5 +238,66 @@ class CoderRemoteConnectionHandle { } return Pair(tokenFromUser, tokenSource) } + + /** + * Return if the URL is whitelisted, https, and the URL and its final + * destination, if it is a different host. + */ + @JvmStatic + fun isWhitelisted(url: URL, deploymentURL: URL): Triple { + // TODO: Setting for the whitelist, and remember previously allowed + // domains. + val domainWhitelist = listOf("intellij.net", "jetbrains.com", deploymentURL.host) + + // Resolve any redirects. + val finalUrl = try { + resolveRedirects(url) + } catch (e: Exception) { + when (e) { + is SSLHandshakeException -> + throw Exception(CoderGatewayBundle.message( + "gateway.connector.view.workspaces.connect.ssl-error", + url.host, + e.message ?: CoderGatewayBundle.message("gateway.connector.view.workspaces.connect.no-reason") + )) + else -> throw e + } + } + + var linkWithRedirect = url.toString() + if (finalUrl.host != url.host) { + linkWithRedirect = "$linkWithRedirect (redirects to to $finalUrl)" + } + + val whitelisted = domainWhitelist.any { url.host == it || url.host.endsWith(".$it") } + && domainWhitelist.any { finalUrl.host == it || finalUrl.host.endsWith(".$it") } + val https = url.protocol == "https" && finalUrl.protocol == "https" + return Triple(whitelisted, https, linkWithRedirect) + } + + /** + * Follow a URL's redirects to its final destination. + */ + @JvmStatic + fun resolveRedirects(url: URL): URL { + var location = url + val maxRedirects = 10 + for (i in 1..maxRedirects) { + val conn = location.openConnection() as HttpURLConnection + conn.instanceFollowRedirects = false + conn.connect() + val code = conn.responseCode + val nextLocation = conn.getHeaderField("Location"); + conn.disconnect() + // Redirects are triggered by any code starting with 3 plus a + // location header. + if (code < 300 || code >= 400 || nextLocation.isNullOrBlank()) { + return location + } + // Location headers might be relative. + location = URL(location, nextLocation) + } + throw Exception("Too many redirects") + } } } diff --git a/src/test/groovy/CoderRemoteConnectionHandleTest.groovy b/src/test/groovy/CoderRemoteConnectionHandleTest.groovy new file mode 100644 index 00000000..610a9d7a --- /dev/null +++ b/src/test/groovy/CoderRemoteConnectionHandleTest.groovy @@ -0,0 +1,72 @@ +package com.coder.gateway + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpHandler +import com.sun.net.httpserver.HttpServer +import spock.lang.Specification +import spock.lang.Unroll + +@Unroll +class CoderRemoteConnectionHandleTest extends Specification { + /** + * Create, start, and return a server that uses the provided handler. + */ + def mockServer(HttpHandler handler) { + HttpServer srv = HttpServer.create(new InetSocketAddress(0), 0) + srv.createContext("/", handler) + srv.start() + return [srv, "http://localhost:" + srv.address.port] + } + + /** + * Create, start, and return a server that mocks redirects. + */ + def mockRedirectServer(String location, Boolean temp) { + return mockServer(new HttpHandler() { + void handle(HttpExchange exchange) { + exchange.responseHeaders.set("Location", location) + exchange.sendResponseHeaders( + temp ? HttpURLConnection.HTTP_MOVED_TEMP : HttpURLConnection.HTTP_MOVED_PERM, + -1) + exchange.close() + } + }) + } + + def "follows redirects"() { + given: + def (srv1, url1) = mockServer(new HttpHandler() { + void handle(HttpExchange exchange) { + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, -1) + exchange.close() + } + }) + def (srv2, url2) = mockRedirectServer(url1, false) + def (srv3, url3) = mockRedirectServer(url2, true) + + when: + def resolved = CoderRemoteConnectionHandle.resolveRedirects(new URL(url3)) + + then: + resolved.toString() == url1 + + cleanup: + srv1.stop(0) + srv2.stop(0) + srv3.stop(0) + } + + def "follows maximum redirects"() { + given: + def (srv, url) = mockRedirectServer(".", true) + + when: + CoderRemoteConnectionHandle.resolveRedirects(new URL(url)) + + then: + thrown(Exception) + + cleanup: + srv.stop(0) + } +} From 64cf76d3ade9f5faa2e7abc10e625e4bac9a47ea Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 18 Aug 2023 08:17:24 -0800 Subject: [PATCH 13/14] s/whitelist/allowlist --- .../gateway/CoderGatewayConnectionProvider.kt | 16 ++++++++-------- .../coder/gateway/CoderRemoteConnectionHandle.kt | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt index b1cc1f37..1218b180 100644 --- a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt +++ b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt @@ -116,7 +116,7 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider { } // Check that both the domain and the redirected domain are - // whitelisted. If not, check with the user whether to proceed. + // allowlisted. If not, check with the user whether to proceed. verifyDownloadLink(parameters, deploymentURL.toURL()) // TODO: Ask for the project path if missing and validate the path. @@ -159,7 +159,7 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider { } /** - * Check that the link is whitelisted. If not, confirm with the user. + * Check that the link is allowlisted. If not, confirm with the user. */ private fun verifyDownloadLink(parameters: Map, deploymentURL: URL) { val link = parameters[IDE_DOWNLOAD_LINK] @@ -173,25 +173,25 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider { throw IllegalArgumentException("$link is not a valid URL") } - val (whitelisted, https, linkWithRedirect) = try { - CoderRemoteConnectionHandle.isWhitelisted(url, deploymentURL) + val (allowlisted, https, linkWithRedirect) = try { + CoderRemoteConnectionHandle.isAllowlisted(url, deploymentURL) } catch (e: Exception) { throw IllegalArgumentException("Unable to verify $url: $e") } - if (whitelisted && https) { + if (allowlisted && https) { return } - val comment = if (whitelisted) "The download link is from a non-whitelisted URL" + val comment = if (allowlisted) "The download link is from a non-allowlisted URL" else if (https) "The download link is not using HTTPS" - else "The download link is from a non-whitelisted URL and is not using HTTPS" + else "The download link is from a non-allowlisted URL and is not using HTTPS" if (!CoderRemoteConnectionHandle.confirm( "Confirm download URL", "$comment. Would you like to proceed?", linkWithRedirect, )) { - throw IllegalArgumentException("$linkWithRedirect is not whitelisted") + throw IllegalArgumentException("$linkWithRedirect is not allowlisted") } } diff --git a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt index c1b2c606..41549b9c 100644 --- a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt +++ b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt @@ -240,14 +240,14 @@ class CoderRemoteConnectionHandle { } /** - * Return if the URL is whitelisted, https, and the URL and its final + * Return if the URL is allowlisted, https, and the URL and its final * destination, if it is a different host. */ @JvmStatic - fun isWhitelisted(url: URL, deploymentURL: URL): Triple { - // TODO: Setting for the whitelist, and remember previously allowed + fun isAllowlisted(url: URL, deploymentURL: URL): Triple { + // TODO: Setting for the allowlist, and remember previously allowed // domains. - val domainWhitelist = listOf("intellij.net", "jetbrains.com", deploymentURL.host) + val domainAllowlist = listOf("intellij.net", "jetbrains.com", deploymentURL.host) // Resolve any redirects. val finalUrl = try { @@ -269,10 +269,10 @@ class CoderRemoteConnectionHandle { linkWithRedirect = "$linkWithRedirect (redirects to to $finalUrl)" } - val whitelisted = domainWhitelist.any { url.host == it || url.host.endsWith(".$it") } - && domainWhitelist.any { finalUrl.host == it || finalUrl.host.endsWith(".$it") } + val allowlisted = domainAllowlist.any { url.host == it || url.host.endsWith(".$it") } + && domainAllowlist.any { finalUrl.host == it || finalUrl.host.endsWith(".$it") } val https = url.protocol == "https" && finalUrl.protocol == "https" - return Triple(whitelisted, https, linkWithRedirect) + return Triple(allowlisted, https, linkWithRedirect) } /** From e0b829399e1141ce92e8938324e780bc5379a5de Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 18 Aug 2023 08:19:31 -0800 Subject: [PATCH 14/14] Remove deployment host The deployment host is probably not exactly where the IDEs would be stored if self-serving. Probably at most they would share a root domain. For now remove it, we can add it back or something like it back if we figure out some usage patterns, although making it configurable is probably the better bet anyway. --- .../com/coder/gateway/CoderGatewayConnectionProvider.kt | 6 +++--- .../kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt index 1218b180..03c99f23 100644 --- a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt +++ b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt @@ -117,7 +117,7 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider { // Check that both the domain and the redirected domain are // allowlisted. If not, check with the user whether to proceed. - verifyDownloadLink(parameters, deploymentURL.toURL()) + verifyDownloadLink(parameters) // TODO: Ask for the project path if missing and validate the path. val folder = parameters[FOLDER] ?: throw IllegalArgumentException("Query parameter \"$FOLDER\" is missing") @@ -161,7 +161,7 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider { /** * Check that the link is allowlisted. If not, confirm with the user. */ - private fun verifyDownloadLink(parameters: Map, deploymentURL: URL) { + private fun verifyDownloadLink(parameters: Map) { val link = parameters[IDE_DOWNLOAD_LINK] if (link.isNullOrBlank()) { return // Nothing to verify @@ -174,7 +174,7 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider { } val (allowlisted, https, linkWithRedirect) = try { - CoderRemoteConnectionHandle.isAllowlisted(url, deploymentURL) + CoderRemoteConnectionHandle.isAllowlisted(url) } catch (e: Exception) { throw IllegalArgumentException("Unable to verify $url: $e") } diff --git a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt index 41549b9c..3ebee47d 100644 --- a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt +++ b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt @@ -244,10 +244,10 @@ class CoderRemoteConnectionHandle { * destination, if it is a different host. */ @JvmStatic - fun isAllowlisted(url: URL, deploymentURL: URL): Triple { + fun isAllowlisted(url: URL): Triple { // TODO: Setting for the allowlist, and remember previously allowed // domains. - val domainAllowlist = listOf("intellij.net", "jetbrains.com", deploymentURL.host) + val domainAllowlist = listOf("intellij.net", "jetbrains.com") // Resolve any redirects. val finalUrl = try {