|
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