Skip to content

Commit 5e11642

Browse files
committed
Break out link handler
This is to share as much as possible with the Toolbox branch. Part of this added a URI query param parser, since in Toolbox we get the raw URI and there does not seem to be anything in Kotlin to parse query parameters.
1 parent 8adf608 commit 5e11642

11 files changed

+699
-646
lines changed

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

+5-323
Original file line numberDiff line numberDiff line change
@@ -2,94 +2,14 @@
22

33
package com.coder.gateway
44

5-
import com.coder.gateway.cli.CoderCLIManager
6-
import com.coder.gateway.cli.ensureCLI
7-
import com.coder.gateway.models.AGENT_ID
8-
import com.coder.gateway.models.AGENT_NAME
9-
import com.coder.gateway.models.TOKEN
10-
import com.coder.gateway.models.URL
11-
import com.coder.gateway.models.WORKSPACE
12-
import com.coder.gateway.models.WorkspaceAndAgentStatus
13-
import com.coder.gateway.models.WorkspaceProjectIDE
14-
import com.coder.gateway.models.agentID
15-
import com.coder.gateway.models.agentName
16-
import com.coder.gateway.models.folder
17-
import com.coder.gateway.models.ideBuildNumber
18-
import com.coder.gateway.models.ideDownloadLink
19-
import com.coder.gateway.models.idePathOnHost
20-
import com.coder.gateway.models.ideProductCode
21-
import com.coder.gateway.models.isCoder
22-
import com.coder.gateway.models.token
23-
import com.coder.gateway.models.url
24-
import com.coder.gateway.models.workspace
25-
import com.coder.gateway.sdk.CoderRestClient
26-
import com.coder.gateway.sdk.ex.APIResponseException
27-
import com.coder.gateway.sdk.v2.models.Workspace
28-
import com.coder.gateway.sdk.v2.models.WorkspaceAgent
29-
import com.coder.gateway.sdk.v2.models.WorkspaceStatus
30-
import com.coder.gateway.services.CoderRestClientService
315
import com.coder.gateway.services.CoderSettingsService
32-
import com.coder.gateway.settings.Source
33-
import com.coder.gateway.util.toURL
34-
import com.coder.gateway.views.steps.CoderWorkspaceProjectIDEStepView
35-
import com.coder.gateway.views.steps.CoderWorkspacesStepSelection
36-
import com.intellij.openapi.application.ApplicationManager
6+
import com.coder.gateway.util.handleLink
7+
import com.coder.gateway.util.isCoder
378
import com.intellij.openapi.components.service
389
import com.intellij.openapi.diagnostic.Logger
39-
import com.intellij.openapi.ui.DialogWrapper
40-
import com.intellij.ui.dsl.builder.panel
41-
import com.intellij.util.ui.JBUI
4210
import com.jetbrains.gateway.api.ConnectionRequestor
4311
import com.jetbrains.gateway.api.GatewayConnectionHandle
4412
import com.jetbrains.gateway.api.GatewayConnectionProvider
45-
import javax.swing.JComponent
46-
import javax.swing.border.Border
47-
48-
/**
49-
* A dialog wrapper around CoderWorkspaceStepView.
50-
*/
51-
class CoderWorkspaceStepDialog(
52-
name: String,
53-
private val state: CoderWorkspacesStepSelection,
54-
) : DialogWrapper(true) {
55-
private val view = CoderWorkspaceProjectIDEStepView(showTitle = false)
56-
57-
init {
58-
init()
59-
title = CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.choose.text", name)
60-
}
61-
62-
override fun show() {
63-
view.init(state)
64-
view.onPrevious = { close(1) }
65-
view.onNext = { close(0) }
66-
super.show()
67-
view.dispose()
68-
}
69-
70-
fun showAndGetData(): WorkspaceProjectIDE? {
71-
if (showAndGet()) {
72-
return view.data()
73-
}
74-
return null
75-
}
76-
77-
override fun createContentPaneBorder(): Border {
78-
return JBUI.Borders.empty()
79-
}
80-
81-
override fun createCenterPanel(): JComponent {
82-
return view
83-
}
84-
85-
override fun createSouthPanel(): JComponent {
86-
// The plugin provides its own buttons.
87-
// TODO: Is it more idiomatic to handle buttons out here?
88-
return panel {}.apply {
89-
border = JBUI.Borders.empty()
90-
}
91-
}
92-
}
9313

9414
// CoderGatewayConnectionProvider handles connecting via a Gateway link such as
9515
// jetbrains-gateway://connect#type=coder.
@@ -101,204 +21,14 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider {
10121
requestor: ConnectionRequestor,
10222
): GatewayConnectionHandle? {
10323
CoderRemoteConnectionHandle().connect { indicator ->
104-
logger.debug("Launched Coder connection provider", parameters)
105-
106-
val deploymentURL =
107-
parameters.url()
108-
?: CoderRemoteConnectionHandle.ask("Enter the full URL of your Coder deployment")
109-
if (deploymentURL.isNullOrBlank()) {
110-
throw IllegalArgumentException("Query parameter \"$URL\" is missing")
111-
}
112-
113-
val client = authenticate(deploymentURL, parameters.token())
114-
115-
// TODO: If the workspace is missing we could launch the wizard.
116-
val workspaceName = parameters.workspace() ?: throw IllegalArgumentException("Query parameter \"$WORKSPACE\" is missing")
117-
118-
val workspaces = client.workspaces()
119-
val workspace =
120-
workspaces.firstOrNull {
121-
it.name == workspaceName
122-
} ?: throw IllegalArgumentException("The workspace $workspaceName does not exist")
123-
124-
when (workspace.latestBuild.status) {
125-
WorkspaceStatus.PENDING, WorkspaceStatus.STARTING ->
126-
// TODO: Wait for the workspace to turn on.
127-
throw IllegalArgumentException(
128-
"The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; please wait then try again",
129-
)
130-
WorkspaceStatus.STOPPING, WorkspaceStatus.STOPPED,
131-
WorkspaceStatus.CANCELING, WorkspaceStatus.CANCELED,
132-
->
133-
// TODO: Turn on the workspace.
134-
throw IllegalArgumentException(
135-
"The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; please start the workspace and try again",
136-
)
137-
WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED ->
138-
throw IllegalArgumentException(
139-
"The workspace \"$workspaceName\" is ${workspace.latestBuild.status.toString().lowercase()}; unable to connect",
140-
)
141-
WorkspaceStatus.RUNNING -> Unit // All is well
142-
}
143-
144-
// TODO: Show a dropdown and ask for an agent if missing.
145-
val agent = getMatchingAgent(parameters, workspace)
146-
val status = WorkspaceAndAgentStatus.from(workspace, agent)
147-
148-
if (status.pending()) {
149-
// TODO: Wait for the agent to be ready.
150-
throw IllegalArgumentException(
151-
"The agent \"${agent.name}\" is ${status.toString().lowercase()}; please wait then try again",
152-
)
153-
} else if (!status.ready()) {
154-
throw IllegalArgumentException("The agent \"${agent.name}\" is ${status.toString().lowercase()}; unable to connect")
155-
}
156-
157-
val cli =
158-
ensureCLI(
159-
deploymentURL.toURL(),
160-
client.buildInfo().version,
161-
settings,
162-
indicator,
163-
)
164-
165-
// We only need to log in if we are using token-based auth.
166-
if (client.token !== null) {
167-
indicator.text = "Authenticating Coder CLI..."
168-
cli.login(client.token)
169-
}
170-
171-
indicator.text = "Configuring Coder CLI..."
172-
cli.configSsh(client.agentNames(workspaces))
173-
174-
val name = "${workspace.name}.${agent.name}"
175-
val openDialog =
176-
parameters.ideProductCode().isNullOrBlank() ||
177-
parameters.ideBuildNumber().isNullOrBlank() ||
178-
(parameters.idePathOnHost().isNullOrBlank() && parameters.ideDownloadLink().isNullOrBlank()) ||
179-
parameters.folder().isNullOrBlank()
180-
181-
if (openDialog) {
182-
var data: WorkspaceProjectIDE? = null
183-
ApplicationManager.getApplication().invokeAndWait {
184-
val dialog =
185-
CoderWorkspaceStepDialog(
186-
name,
187-
CoderWorkspacesStepSelection(agent, workspace, cli, client, workspaces),
188-
)
189-
data = dialog.showAndGetData()
190-
}
191-
data ?: throw Exception("IDE selection aborted; unable to connect")
192-
} else {
193-
// Check that both the domain and the redirected domain are
194-
// allowlisted. If not, check with the user whether to proceed.
195-
verifyDownloadLink(parameters)
196-
WorkspaceProjectIDE.fromInputs(
197-
name = name,
198-
hostname = CoderCLIManager.getHostName(deploymentURL.toURL(), name),
199-
projectPath = parameters.folder(),
200-
ideProductCode = parameters.ideProductCode(),
201-
ideBuildNumber = parameters.ideBuildNumber(),
202-
idePathOnHost = parameters.idePathOnHost(),
203-
downloadSource = parameters.ideDownloadLink(),
204-
deploymentURL = deploymentURL,
205-
lastOpened = null, // Have not opened yet.
206-
)
24+
logger.debug("Launched Coder link handler", parameters)
25+
handleLink(parameters, settings) {
26+
indicator.text = it
20727
}
20828
}
20929
return null
21030
}
21131

212-
/**
213-
* Return an authenticated Coder CLI, asking for the token as long as it
214-
* continues to result in an authentication failure and token authentication
215-
* is required.
216-
*/
217-
private fun authenticate(
218-
deploymentURL: String,
219-
queryToken: String?,
220-
lastToken: Pair<String, Source>? = null,
221-
): CoderRestClient {
222-
val token =
223-
if (settings.requireTokenAuth) {
224-
// Use the token from the query, unless we already tried that.
225-
val isRetry = lastToken != null
226-
if (!queryToken.isNullOrBlank() && !isRetry) {
227-
Pair(queryToken, Source.QUERY)
228-
} else {
229-
CoderRemoteConnectionHandle.askToken(
230-
deploymentURL.toURL(),
231-
lastToken,
232-
isRetry,
233-
useExisting = true,
234-
settings,
235-
)
236-
}
237-
} else {
238-
null
239-
}
240-
if (settings.requireTokenAuth && token == null) { // User aborted.
241-
throw IllegalArgumentException("Unable to connect to $deploymentURL, query parameter \"$TOKEN\" is missing")
242-
}
243-
val client = CoderRestClientService(deploymentURL.toURL(), token?.first)
244-
return try {
245-
client.authenticate()
246-
client
247-
} catch (ex: APIResponseException) {
248-
// If doing token auth we can ask and try again.
249-
if (settings.requireTokenAuth && ex.isUnauthorized) {
250-
authenticate(deploymentURL, queryToken, token)
251-
} else {
252-
throw ex
253-
}
254-
}
255-
}
256-
257-
/**
258-
* Check that the link is allowlisted. If not, confirm with the user.
259-
*/
260-
private fun verifyDownloadLink(parameters: Map<String, String>) {
261-
val link = parameters.ideDownloadLink()
262-
if (link.isNullOrBlank()) {
263-
return // Nothing to verify
264-
}
265-
266-
val url =
267-
try {
268-
link.toURL()
269-
} catch (ex: Exception) {
270-
throw IllegalArgumentException("$link is not a valid URL")
271-
}
272-
273-
val (allowlisted, https, linkWithRedirect) =
274-
try {
275-
CoderRemoteConnectionHandle.isAllowlisted(url)
276-
} catch (e: Exception) {
277-
throw IllegalArgumentException("Unable to verify $url: $e")
278-
}
279-
if (allowlisted && https) {
280-
return
281-
}
282-
283-
val comment =
284-
if (allowlisted) {
285-
"The download link is from a non-allowlisted URL"
286-
} else if (https) {
287-
"The download link is not using HTTPS"
288-
} else {
289-
"The download link is from a non-allowlisted URL and is not using HTTPS"
290-
}
291-
292-
if (!CoderRemoteConnectionHandle.confirm(
293-
"Confirm download URL",
294-
"$comment. Would you like to proceed?",
295-
linkWithRedirect,
296-
)
297-
) {
298-
throw IllegalArgumentException("$linkWithRedirect is not allowlisted")
299-
}
300-
}
301-
30232
override fun isApplicable(parameters: Map<String, String>): Boolean {
30333
return parameters.isCoder()
30434
}
@@ -307,51 +37,3 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider {
30737
val logger = Logger.getInstance(CoderGatewayConnectionProvider::class.java.simpleName)
30838
}
30939
}
310-
311-
/**
312-
* Return the agent matching the provided agent ID or name in the parameters.
313-
* The name is ignored if the ID is set. If neither was supplied and the
314-
* workspace has only one agent, return that. Otherwise throw an error.
315-
*
316-
* @throws [MissingArgumentException, IllegalArgumentException]
317-
*/
318-
fun getMatchingAgent(
319-
parameters: Map<String, String?>,
320-
workspace: Workspace,
321-
): WorkspaceAgent {
322-
val agents = workspace.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! }
323-
if (agents.isEmpty()) {
324-
throw IllegalArgumentException("The workspace \"${workspace.name}\" has no agents")
325-
}
326-
327-
// If the agent is missing and the workspace has only one, use that.
328-
// Prefer the ID over the name if both are set.
329-
val agent =
330-
if (!parameters.agentID().isNullOrBlank()) {
331-
agents.firstOrNull { it.id.toString() == parameters.agentID() }
332-
} else if (!parameters.agentName().isNullOrBlank()) {
333-
agents.firstOrNull { it.name == parameters.agentName() }
334-
} else if (agents.size == 1) {
335-
agents.first()
336-
} else {
337-
null
338-
}
339-
340-
if (agent == null) {
341-
if (!parameters.agentID().isNullOrBlank()) {
342-
throw IllegalArgumentException("The workspace \"${workspace.name}\" does not have an agent with ID \"${parameters.agentID()}\"")
343-
} else if (!parameters.agentName().isNullOrBlank()) {
344-
throw IllegalArgumentException(
345-
"The workspace \"${workspace.name}\"does not have an agent named \"${parameters.agentName()}\"",
346-
)
347-
} else {
348-
throw MissingArgumentException(
349-
"Unable to determine which agent to connect to; one of \"$AGENT_NAME\" or \"$AGENT_ID\" must be set because the workspace \"${workspace.name}\" has more than one agent",
350-
)
351-
}
352-
}
353-
354-
return agent
355-
}
356-
357-
class MissingArgumentException(message: String) : IllegalArgumentException(message)

0 commit comments

Comments
 (0)