Skip to content

Commit ab69920

Browse files
committed
Handle Gateway links
1 parent 5569d46 commit ab69920

6 files changed

+127
-8
lines changed

README.md

+6
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ To manually install a local build:
3939

4040
Alternatively, `./gradlew clean runIde` will deploy a Gateway distribution (the one specified in `gradle.properties` - `platformVersion`) with the latest plugin changes deployed.
4141

42+
To simulate opening a workspace from the dashboard pass the Gateway link via `--args`. For example:
43+
44+
```
45+
./gradlew clean runIDE --args="jetbrains-gateway://connect#type=coder&coderHost=https://dev.coder.com&workspaceName=dev"
46+
```
47+
4248
### Plugin Structure
4349

4450
```

src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt

+113-2
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,129 @@
22

33
package com.coder.gateway
44

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.toAgentModels
11+
import com.coder.gateway.sdk.withPath
12+
import com.coder.gateway.services.CoderSettingsState
13+
import com.intellij.openapi.components.service
514
import com.intellij.openapi.diagnostic.Logger
615
import com.jetbrains.gateway.api.ConnectionRequestor
716
import com.jetbrains.gateway.api.GatewayConnectionHandle
817
import com.jetbrains.gateway.api.GatewayConnectionProvider
18+
import java.net.URL
919

20+
// In addition to parameters `type`, these are the keys that we support in our
21+
// Gateway links.
22+
private const val URL = "url"
23+
private const val TOKEN = "token"
24+
private const val WORKSPACE = "workspace"
25+
private const val AGENT = "agent"
26+
private const val FOLDER = "folder"
27+
private const val IDE_DOWNLOAD_LINK = "ide_download_link"
28+
private const val IDE_PRODUCT_CODE = "ide_product_code"
29+
private const val IDE_BUILD_NUMBER = "ide_build_number"
30+
private const val IDE_PATH_ON_HOST = "ide_path_on_host"
31+
32+
// CoderGatewayConnectionProvider handles connecting via a Gateway link such as
33+
// jetbrains-gateway://connect#type=coder.
1034
class CoderGatewayConnectionProvider : GatewayConnectionProvider {
35+
private val settings: CoderSettingsState = service()
36+
1137
override suspend fun connect(parameters: Map<String, String>, requestor: ConnectionRequestor): GatewayConnectionHandle? {
12-
logger.debug("Launched Coder connection provider", parameters)
13-
CoderRemoteConnectionHandle().connect(parameters)
38+
CoderRemoteConnectionHandle().connect{ indicator ->
39+
logger.debug("Launched Coder connection provider", parameters)
40+
41+
val deploymentURL = parameters[URL]
42+
?: CoderRemoteConnectionHandle.ask("Enter the full URL of your Coder deployment")
43+
if (deploymentURL.isNullOrBlank()) {
44+
throw IllegalArgumentException("Query parameter \"$URL\" is missing")
45+
}
46+
47+
val (client, username) = authenticate(deploymentURL.toURL(), parameters[TOKEN])
48+
49+
// TODO: If these are missing we could launch the wizard.
50+
val name = parameters[WORKSPACE] ?: throw IllegalArgumentException("Query parameter \"$WORKSPACE\" is missing")
51+
val agent = parameters[AGENT] ?: throw IllegalArgumentException("Query parameter \"$AGENT\" is missing")
52+
53+
val workspaces = client.workspaces()
54+
val agents = workspaces.flatMap { it.toAgentModels() }
55+
val workspace = agents.firstOrNull { it.name == "$name.$agent" }
56+
?: throw IllegalArgumentException("The agent $agent does not exist on the workspace $name or the workspace is off")
57+
58+
// TODO: Turn on the workspace if it is off then wait for the agent
59+
// to be ready. Also, distinguish between whether the
60+
// workspace is off or the agent does not exist in the error
61+
// above instead of showing a combined error.
62+
63+
val cli = CoderCLIManager.ensureCLI(
64+
deploymentURL.toURL(),
65+
client.buildInfo().version,
66+
settings,
67+
indicator,
68+
)
69+
70+
indicator.text = "Authenticating Coder CLI..."
71+
cli.login(client.token)
72+
73+
indicator.text = "Configuring Coder CLI..."
74+
cli.configSsh(agents)
75+
76+
// TODO: Ask for these if missing. Maybe we can reuse the second
77+
// step of the wizard? Could also be nice if we automatically used
78+
// the last IDE.
79+
if (parameters[IDE_PRODUCT_CODE].isNullOrBlank()) {
80+
throw IllegalArgumentException("Query parameter \"$IDE_PRODUCT_CODE\" is missing")
81+
}
82+
if (parameters[IDE_BUILD_NUMBER].isNullOrBlank()) {
83+
throw IllegalArgumentException("Query parameter \"$IDE_BUILD_NUMBER\" is missing")
84+
}
85+
if (parameters[IDE_PATH_ON_HOST].isNullOrBlank() && parameters[IDE_DOWNLOAD_LINK].isNullOrBlank()) {
86+
throw IllegalArgumentException("One of \"$IDE_PATH_ON_HOST\" or \"$IDE_DOWNLOAD_LINK\" is required")
87+
}
88+
89+
// TODO: Ask for the project path if missing and validate the path.
90+
val folder = parameters[FOLDER] ?: throw IllegalArgumentException("Query parameter \"$FOLDER\" is missing")
91+
92+
parameters
93+
.withWorkspaceHostname(CoderCLIManager.getHostName(deploymentURL.toURL(), workspace))
94+
.withProjectPath(folder)
95+
.withWebTerminalLink(client.url.withPath("/@$username/$workspace.name/terminal").toString())
96+
.withConfigDirectory(cli.coderConfigPath.toString())
97+
.withName(name)
98+
}
1499
return null
15100
}
16101

102+
/**
103+
* Return an authenticated Coder CLI and the user's name, asking for the
104+
* token as long as it continues to result in an authentication failure.
105+
*/
106+
private fun authenticate(deploymentURL: URL, queryToken: String?, lastToken: Pair<String, TokenSource>? = null): Pair<CoderRestClient, String> {
107+
// Use the token from the query, unless we already tried that.
108+
val isRetry = lastToken != null
109+
val token = if (!queryToken.isNullOrBlank() && !isRetry)
110+
Pair(queryToken, TokenSource.QUERY)
111+
else CoderRemoteConnectionHandle.askToken(
112+
deploymentURL,
113+
lastToken,
114+
isRetry,
115+
useExisting = true,
116+
)
117+
if (token == null) { // User aborted.
118+
throw IllegalArgumentException("Unable to connect to $deploymentURL, $TOKEN is missing")
119+
}
120+
val client = CoderRestClient(deploymentURL, token.first)
121+
return try {
122+
Pair(client, client.me().username)
123+
} catch (ex: AuthenticationResponseException) {
124+
authenticate(deploymentURL, queryToken, token)
125+
}
126+
}
127+
17128
override fun isApplicable(parameters: Map<String, String>): Boolean {
18129
return parameters.areCoderType()
19130
}

src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt

+4-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import com.intellij.openapi.application.ApplicationManager
1616
import com.intellij.openapi.application.ModalityState
1717
import com.intellij.openapi.components.service
1818
import com.intellij.openapi.diagnostic.Logger
19+
import com.intellij.openapi.progress.ProgressIndicator
1920
import com.intellij.openapi.rd.util.launchUnderBackgroundProgress
2021
import com.intellij.openapi.ui.Messages
2122
import com.intellij.openapi.ui.panel.ComponentPanelBuilder
@@ -44,11 +45,12 @@ import java.util.concurrent.TimeoutException
4445
class CoderRemoteConnectionHandle {
4546
private val recentConnectionsService = service<CoderRecentWorkspaceConnectionsService>()
4647

47-
suspend fun connect(parameters: Map<String, String>) {
48-
logger.debug("Creating connection handle", parameters)
48+
suspend fun connect(getParameters: (indicator: ProgressIndicator) -> Map<String, String>) {
4949
val clientLifetime = LifetimeDefinition()
5050
clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title"), canBeCancelled = true, isIndeterminate = true, project = null) {
5151
try {
52+
val parameters = getParameters(indicator)
53+
logger.debug("Creating connection handle", parameters)
5254
indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting")
5355
val context = suspendingRetryWithExponentialBackOff(
5456
action = { attempt ->

src/main/kotlin/com/coder/gateway/WorkspaceParams.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ fun Map<String, String>.withName(name: String): Map<String, String> {
9898

9999

100100
fun Map<String, String>.areCoderType(): Boolean {
101-
return this[TYPE] == VALUE_FOR_TYPE && !this[CODER_WORKSPACE_HOSTNAME].isNullOrBlank() && !this[PROJECT_PATH].isNullOrBlank()
101+
return this[TYPE] == VALUE_FOR_TYPE
102102
}
103103

104104
fun Map<String, String>.toSshConfig(): SshConfig {

src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback:
216216
icon(product.icon)
217217
cell(ActionLink(connectionDetails.projectPath!!) {
218218
cs.launch {
219-
CoderRemoteConnectionHandle().connect(connectionDetails.toWorkspaceParams())
219+
CoderRemoteConnectionHandle().connect{ connectionDetails.toWorkspaceParams() }
220220
GatewayUI.getInstance().reset()
221221
}
222222
})

src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -339,15 +339,15 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
339339
return false
340340
}
341341
cs.launch {
342-
CoderRemoteConnectionHandle().connect(
342+
CoderRemoteConnectionHandle().connect{
343343
selectedIDE
344344
.toWorkspaceParams()
345345
.withWorkspaceHostname(CoderCLIManager.getHostName(deploymentURL, selectedWorkspace))
346346
.withProjectPath(tfProject.text)
347347
.withWebTerminalLink("${terminalLink.url}")
348348
.withConfigDirectory(wizardModel.configDirectory)
349349
.withName(selectedWorkspace.name)
350-
)
350+
}
351351
GatewayUI.getInstance().reset()
352352
}
353353
return true

0 commit comments

Comments
 (0)