|
2 | 2 |
|
3 | 3 | package com.coder.gateway |
4 | 4 |
|
5 | | -import com.coder.gateway.sdk.humanizeDuration |
6 | | -import com.coder.gateway.sdk.isCancellation |
7 | | -import com.coder.gateway.sdk.isWorkerTimeout |
8 | | -import com.coder.gateway.sdk.suspendingRetryWithExponentialBackOff |
9 | | -import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService |
10 | | -import com.intellij.openapi.application.ApplicationManager |
| 5 | +import com.coder.gateway.models.TokenSource |
| 6 | +import com.coder.gateway.sdk.CoderCLIManager |
| 7 | +import com.coder.gateway.sdk.CoderRestClient |
| 8 | +import com.coder.gateway.sdk.ex.AuthenticationResponseException |
| 9 | +import com.coder.gateway.sdk.toURL |
| 10 | +import com.coder.gateway.sdk.v2.models.WorkspaceStatus |
| 11 | +import com.coder.gateway.sdk.v2.models.toAgentModels |
| 12 | +import com.coder.gateway.sdk.withPath |
| 13 | +import com.coder.gateway.services.CoderSettingsState |
11 | 14 | import com.intellij.openapi.components.service |
12 | 15 | import com.intellij.openapi.diagnostic.Logger |
13 | | -import com.intellij.openapi.rd.util.launchUnderBackgroundProgress |
14 | | -import com.intellij.openapi.ui.Messages |
15 | 16 | import com.jetbrains.gateway.api.ConnectionRequestor |
16 | 17 | import com.jetbrains.gateway.api.GatewayConnectionHandle |
17 | 18 | import com.jetbrains.gateway.api.GatewayConnectionProvider |
18 | | -import com.jetbrains.gateway.api.GatewayUI |
19 | | -import com.jetbrains.gateway.ssh.SshDeployFlowUtil |
20 | | -import com.jetbrains.gateway.ssh.SshMultistagePanelContext |
21 | | -import com.jetbrains.gateway.ssh.deploy.DeployException |
22 | | -import com.jetbrains.rd.util.lifetime.LifetimeDefinition |
23 | | -import kotlinx.coroutines.launch |
24 | | -import net.schmizz.sshj.common.SSHException |
25 | | -import net.schmizz.sshj.connection.ConnectionException |
26 | | -import java.time.Duration |
27 | | -import java.util.concurrent.TimeoutException |
| 19 | +import java.net.URL |
28 | 20 |
|
| 21 | +// In addition to `type`, these are the keys that we support in our Gateway |
| 22 | +// links. |
| 23 | +private const val URL = "url" |
| 24 | +private const val TOKEN = "token" |
| 25 | +private const val WORKSPACE = "workspace" |
| 26 | +private const val AGENT = "agent" |
| 27 | +private const val FOLDER = "folder" |
| 28 | +private const val IDE_DOWNLOAD_LINK = "ide_download_link" |
| 29 | +private const val IDE_PRODUCT_CODE = "ide_product_code" |
| 30 | +private const val IDE_BUILD_NUMBER = "ide_build_number" |
| 31 | +private const val IDE_PATH_ON_HOST = "ide_path_on_host" |
| 32 | + |
| 33 | +// CoderGatewayConnectionProvider handles connecting via a Gateway link such as |
| 34 | +// jetbrains-gateway://connect#type=coder. |
29 | 35 | class CoderGatewayConnectionProvider : GatewayConnectionProvider { |
30 | | - private val recentConnectionsService = service<CoderRecentWorkspaceConnectionsService>() |
| 36 | + private val settings: CoderSettingsState = service() |
31 | 37 |
|
32 | 38 | override suspend fun connect(parameters: Map<String, String>, requestor: ConnectionRequestor): GatewayConnectionHandle? { |
33 | | - val clientLifetime = LifetimeDefinition() |
34 | | - // TODO: If this fails determine if it is an auth error and if so prompt |
35 | | - // for a new token, configure the CLI, then try again. |
36 | | - clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title"), canBeCancelled = true, isIndeterminate = true, project = null) { |
37 | | - try { |
38 | | - indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting") |
39 | | - val context = suspendingRetryWithExponentialBackOff( |
40 | | - action = { attempt -> |
41 | | - logger.info("Connecting... (attempt $attempt") |
42 | | - if (attempt > 1) { |
43 | | - // indicator.text is the text above the progress bar. |
44 | | - indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting.retry", attempt) |
45 | | - } |
46 | | - SshMultistagePanelContext(parameters.toHostDeployInputs()) |
47 | | - }, |
48 | | - retryIf = { |
49 | | - it is ConnectionException || it is TimeoutException |
50 | | - || it is SSHException || it is DeployException |
51 | | - }, |
52 | | - onException = { attempt, nextMs, e -> |
53 | | - logger.error("Failed to connect (attempt $attempt; will retry in $nextMs ms)") |
54 | | - // indicator.text2 is the text below the progress bar. |
55 | | - indicator.text2 = |
56 | | - if (isWorkerTimeout(e)) "Failed to upload worker binary...it may have timed out" |
57 | | - else e.message ?: CoderGatewayBundle.message("gateway.connector.no-details") |
58 | | - }, |
59 | | - onCountdown = { remainingMs -> |
60 | | - indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting.failed.retry", humanizeDuration(remainingMs)) |
61 | | - }, |
62 | | - ) |
63 | | - launch { |
64 | | - logger.info("Deploying and starting IDE with $context") |
65 | | - // At this point JetBrains takes over with their own UI. |
66 | | - @Suppress("UnstableApiUsage") SshDeployFlowUtil.fullDeployCycle( |
67 | | - clientLifetime, context, Duration.ofMinutes(10) |
68 | | - ) |
69 | | - } |
70 | | - } catch (e: Exception) { |
71 | | - if (isCancellation(e)) { |
72 | | - logger.info("Connection canceled due to ${e.javaClass}") |
73 | | - } else { |
74 | | - logger.info("Failed to connect (will not retry)", e) |
75 | | - // The dialog will close once we return so write the error |
76 | | - // out into a new dialog. |
77 | | - ApplicationManager.getApplication().invokeAndWait { |
78 | | - Messages.showMessageDialog( |
79 | | - e.message ?: CoderGatewayBundle.message("gateway.connector.no-details"), |
80 | | - CoderGatewayBundle.message("gateway.connector.coder.connection.failed"), |
81 | | - Messages.getErrorIcon()) |
82 | | - } |
83 | | - } |
| 39 | + CoderRemoteConnectionHandle().connect{ indicator -> |
| 40 | + logger.debug("Launched Coder connection provider", parameters) |
| 41 | + |
| 42 | + val deploymentURL = parameters[URL] |
| 43 | + ?: CoderRemoteConnectionHandle.ask("Enter the full URL of your Coder deployment") |
| 44 | + if (deploymentURL.isNullOrBlank()) { |
| 45 | + throw IllegalArgumentException("Query parameter \"$URL\" is missing") |
84 | 46 | } |
85 | | - } |
86 | 47 |
|
87 | | - recentConnectionsService.addRecentConnection(parameters.toRecentWorkspaceConnection()) |
88 | | - GatewayUI.getInstance().reset() |
| 48 | + val (client, username) = authenticate(deploymentURL.toURL(), parameters[TOKEN]) |
| 49 | + |
| 50 | + // TODO: If the workspace is missing we could launch the wizard. |
| 51 | + val workspaceName = parameters[WORKSPACE] ?: throw IllegalArgumentException("Query parameter \"$WORKSPACE\" is missing") |
| 52 | + |
| 53 | + val workspaces = client.workspaces() |
| 54 | + val workspace = workspaces.firstOrNull{ it.name == workspaceName } ?: throw IllegalArgumentException("The workspace $workspaceName does not exist") |
| 55 | + |
| 56 | + when (workspace.latestBuild.status) { |
| 57 | + WorkspaceStatus.PENDING, WorkspaceStatus.STARTING -> |
| 58 | + // TODO: Wait for the workspace to turn on. |
| 59 | + throw IllegalArgumentException("The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; please wait then try again") |
| 60 | + WorkspaceStatus.STOPPING, WorkspaceStatus.STOPPED, |
| 61 | + WorkspaceStatus.CANCELING, WorkspaceStatus.CANCELED -> |
| 62 | + // TODO: Turn on the workspace. |
| 63 | + throw IllegalArgumentException("The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; please start the workspace and try again") |
| 64 | + WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED, -> |
| 65 | + throw IllegalArgumentException("The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; unable to connect") |
| 66 | + WorkspaceStatus.RUNNING -> Unit // All is well |
| 67 | + } |
| 68 | + |
| 69 | + val agents = workspace.toAgentModels() |
| 70 | + if (agents.isEmpty()) { |
| 71 | + throw IllegalArgumentException("The workspace \"$workspaceName\" has no agents") |
| 72 | + } |
| 73 | + |
| 74 | + // If the agent is missing and the workspace has only one, use that. |
| 75 | + val agent = if (!parameters[AGENT].isNullOrBlank()) |
| 76 | + agents.firstOrNull { it.name == "$workspaceName.${parameters[AGENT]}"} |
| 77 | + else if (agents.size == 1) agents.first() |
| 78 | + else null |
| 79 | + |
| 80 | + if (agent == null) { |
| 81 | + // TODO: Show a dropdown and ask for an agent. |
| 82 | + throw IllegalArgumentException("Query parameter \"$AGENT\" is missing") |
| 83 | + } |
| 84 | + |
| 85 | + if (agent.agentStatus.pending()) { |
| 86 | + // TODO: Wait for the agent to be ready. |
| 87 | + throw IllegalArgumentException("The agent \"${agent.name}\" is ${agent.agentStatus.toString().lowercase()}; please wait then try again") |
| 88 | + } else if (!agent.agentStatus.ready()) { |
| 89 | + throw IllegalArgumentException("The agent \"${agent.name}\" is ${agent.agentStatus.toString().lowercase()}; unable to connect") |
| 90 | + } |
| 91 | + |
| 92 | + val cli = CoderCLIManager.ensureCLI( |
| 93 | + deploymentURL.toURL(), |
| 94 | + client.buildInfo().version, |
| 95 | + settings, |
| 96 | + indicator, |
| 97 | + ) |
| 98 | + |
| 99 | + indicator.text = "Authenticating Coder CLI..." |
| 100 | + cli.login(client.token) |
| 101 | + |
| 102 | + indicator.text = "Configuring Coder CLI..." |
| 103 | + cli.configSsh(workspaces.flatMap { it.toAgentModels() }) |
| 104 | + |
| 105 | + // TODO: Ask for these if missing. Maybe we can reuse the second |
| 106 | + // step of the wizard? Could also be nice if we automatically used |
| 107 | + // the last IDE. |
| 108 | + if (parameters[IDE_PRODUCT_CODE].isNullOrBlank()) { |
| 109 | + throw IllegalArgumentException("Query parameter \"$IDE_PRODUCT_CODE\" is missing") |
| 110 | + } |
| 111 | + if (parameters[IDE_BUILD_NUMBER].isNullOrBlank()) { |
| 112 | + throw IllegalArgumentException("Query parameter \"$IDE_BUILD_NUMBER\" is missing") |
| 113 | + } |
| 114 | + if (parameters[IDE_PATH_ON_HOST].isNullOrBlank() && parameters[IDE_DOWNLOAD_LINK].isNullOrBlank()) { |
| 115 | + throw IllegalArgumentException("One of \"$IDE_PATH_ON_HOST\" or \"$IDE_DOWNLOAD_LINK\" is required") |
| 116 | + } |
| 117 | + |
| 118 | + // Check that both the domain and the redirected domain are |
| 119 | + // allowlisted. If not, check with the user whether to proceed. |
| 120 | + verifyDownloadLink(parameters) |
| 121 | + |
| 122 | + // TODO: Ask for the project path if missing and validate the path. |
| 123 | + val folder = parameters[FOLDER] ?: throw IllegalArgumentException("Query parameter \"$FOLDER\" is missing") |
| 124 | + |
| 125 | + parameters |
| 126 | + .withWorkspaceHostname(CoderCLIManager.getHostName(deploymentURL.toURL(), agent)) |
| 127 | + .withProjectPath(folder) |
| 128 | + .withWebTerminalLink(client.url.withPath("/@$username/$workspace.name/terminal").toString()) |
| 129 | + .withConfigDirectory(cli.coderConfigPath.toString()) |
| 130 | + .withName(workspaceName) |
| 131 | + } |
89 | 132 | return null |
90 | 133 | } |
91 | 134 |
|
| 135 | + /** |
| 136 | + * Return an authenticated Coder CLI and the user's name, asking for the |
| 137 | + * token as long as it continues to result in an authentication failure. |
| 138 | + */ |
| 139 | + private fun authenticate(deploymentURL: URL, queryToken: String?, lastToken: Pair<String, TokenSource>? = null): Pair<CoderRestClient, String> { |
| 140 | + // Use the token from the query, unless we already tried that. |
| 141 | + val isRetry = lastToken != null |
| 142 | + val token = if (!queryToken.isNullOrBlank() && !isRetry) |
| 143 | + Pair(queryToken, TokenSource.QUERY) |
| 144 | + else CoderRemoteConnectionHandle.askToken( |
| 145 | + deploymentURL, |
| 146 | + lastToken, |
| 147 | + isRetry, |
| 148 | + useExisting = true, |
| 149 | + ) |
| 150 | + if (token == null) { // User aborted. |
| 151 | + throw IllegalArgumentException("Unable to connect to $deploymentURL, $TOKEN is missing") |
| 152 | + } |
| 153 | + val client = CoderRestClient(deploymentURL, token.first) |
| 154 | + return try { |
| 155 | + Pair(client, client.me().username) |
| 156 | + } catch (ex: AuthenticationResponseException) { |
| 157 | + authenticate(deploymentURL, queryToken, token) |
| 158 | + } |
| 159 | + } |
| 160 | + |
| 161 | + /** |
| 162 | + * Check that the link is allowlisted. If not, confirm with the user. |
| 163 | + */ |
| 164 | + private fun verifyDownloadLink(parameters: Map<String, String>) { |
| 165 | + val link = parameters[IDE_DOWNLOAD_LINK] |
| 166 | + if (link.isNullOrBlank()) { |
| 167 | + return // Nothing to verify |
| 168 | + } |
| 169 | + |
| 170 | + val url = try { |
| 171 | + link.toURL() |
| 172 | + } catch (ex: Exception) { |
| 173 | + throw IllegalArgumentException("$link is not a valid URL") |
| 174 | + } |
| 175 | + |
| 176 | + val (allowlisted, https, linkWithRedirect) = try { |
| 177 | + CoderRemoteConnectionHandle.isAllowlisted(url) |
| 178 | + } catch (e: Exception) { |
| 179 | + throw IllegalArgumentException("Unable to verify $url: $e") |
| 180 | + } |
| 181 | + if (allowlisted && https) { |
| 182 | + return |
| 183 | + } |
| 184 | + |
| 185 | + val comment = if (allowlisted) "The download link is from a non-allowlisted URL" |
| 186 | + else if (https) "The download link is not using HTTPS" |
| 187 | + else "The download link is from a non-allowlisted URL and is not using HTTPS" |
| 188 | + |
| 189 | + if (!CoderRemoteConnectionHandle.confirm( |
| 190 | + "Confirm download URL", |
| 191 | + "$comment. Would you like to proceed?", |
| 192 | + linkWithRedirect, |
| 193 | + )) { |
| 194 | + throw IllegalArgumentException("$linkWithRedirect is not allowlisted") |
| 195 | + } |
| 196 | + } |
| 197 | + |
92 | 198 | override fun isApplicable(parameters: Map<String, String>): Boolean { |
93 | 199 | return parameters.areCoderType() |
94 | 200 | } |
|
0 commit comments