-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathCoderProtocolHandler.kt
339 lines (305 loc) · 13.2 KB
/
CoderProtocolHandler.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
package com.coder.toolbox.util
import com.coder.toolbox.CoderToolboxContext
import com.coder.toolbox.cli.CoderCLIManager
import com.coder.toolbox.cli.ensureCLI
import com.coder.toolbox.models.WorkspaceAndAgentStatus
import com.coder.toolbox.plugin.PluginManager
import com.coder.toolbox.sdk.CoderRestClient
import com.coder.toolbox.sdk.v2.models.Workspace
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
import com.jetbrains.toolbox.api.localization.LocalizableString
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.time.withTimeout
import java.net.HttpURLConnection
import java.net.URI
import java.net.URL
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
open class CoderProtocolHandler(
private val context: CoderToolboxContext,
private val dialogUi: DialogUi,
private val isInitialized: StateFlow<Boolean>,
) {
private val settings = context.settingsStore.readOnly()
/**
* Given a set of URL parameters, prepare the CLI then return a workspace to
* connect.
*
* Throw if required arguments are not supplied or the workspace is not in a
* connectable state.
*/
suspend fun handle(
uri: URI,
shouldWaitForAutoLogin: Boolean,
reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit
) {
context.popupPluginMainPage()
val params = uri.toQueryParameters()
if (params.isEmpty()) {
// probably a plugin installation scenario
return
}
val deploymentURL = params.url() ?: askUrl()
if (deploymentURL.isNullOrBlank()) {
context.logger.error("Query parameter \"$URL\" is missing from URI $uri")
context.showErrorPopup(MissingArgumentException("Can't handle URI because query parameter \"$URL\" is missing"))
return
}
val queryToken = params.token()
val restClient = try {
authenticate(deploymentURL, queryToken)
} catch (ex: Exception) {
context.logger.error(ex, "Query parameter \"$TOKEN\" is missing from URI $uri")
context.showErrorPopup(IllegalStateException(humanizeConnectionError(deploymentURL.toURL(), true, ex)))
return
}
// TODO: Show a dropdown and ask for the workspace if missing. Right now it's not possible because dialogs are quite limited
val workspaceName = params.workspace()
if (workspaceName.isNullOrBlank()) {
context.logger.error("Query parameter \"$WORKSPACE\" is missing from URI $uri")
context.showErrorPopup(MissingArgumentException("Can't handle URI because query parameter \"$WORKSPACE\" is missing"))
return
}
val workspaces = restClient.workspaces()
val workspace = workspaces.firstOrNull { it.name == workspaceName }
if (workspace == null) {
context.logger.error("There is no workspace with name $workspaceName on $deploymentURL")
context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace with name $workspaceName does not exist"))
return
}
when (workspace.latestBuild.status) {
WorkspaceStatus.PENDING, WorkspaceStatus.STARTING ->
if (restClient.waitForReady(workspace) != true) {
context.logger.error("$workspaceName from $deploymentURL could not be ready on time")
context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace $workspaceName could not be ready on time"))
return
}
WorkspaceStatus.STOPPING, WorkspaceStatus.STOPPED,
WorkspaceStatus.CANCELING, WorkspaceStatus.CANCELED -> {
if (settings.disableAutostart) {
context.logger.warn("$workspaceName from $deploymentURL is not started and autostart is disabled.")
context.showInfoPopup(
context.i18n.pnotr("$workspaceName is not running"),
context.i18n.ptrl("Can't handle URI because workspace is not running and autostart is disabled. Please start the workspace manually and execute the URI again."),
context.i18n.ptrl("OK")
)
return
}
try {
restClient.startWorkspace(workspace)
} catch (e: Exception) {
context.logger.error(
e,
"$workspaceName from $deploymentURL could not be started while handling URI"
)
context.showErrorPopup(MissingArgumentException("Can't handle URI because an error was encountered while trying to start workspace $workspaceName"))
return
}
if (restClient.waitForReady(workspace) != true) {
context.logger.error("$workspaceName from $deploymentURL could not be started on time")
context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace $workspaceName could not be started on time"))
return
}
}
WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED -> {
context.logger.error("Unable to connect to $workspaceName from $deploymentURL")
context.showErrorPopup(MissingArgumentException("Can't handle URI because because we're unable to connect to workspace $workspaceName"))
return
}
WorkspaceStatus.RUNNING -> Unit // All is well
}
// TODO: Show a dropdown and ask for an agent if missing.
val agent: WorkspaceAgent
try {
agent = getMatchingAgent(params, workspace)
} catch (e: IllegalArgumentException) {
context.logger.error(e, "Can't resolve an agent for workspace $workspaceName from $deploymentURL")
context.showErrorPopup(
MissingArgumentException(
"Can't handle URI because we can't resolve an agent for workspace $workspaceName from $deploymentURL",
e
)
)
return
}
val status = WorkspaceAndAgentStatus.from(workspace, agent)
if (!status.ready()) {
context.logger.error("Agent ${agent.name} for workspace $workspaceName from $deploymentURL is not ready")
context.showErrorPopup(MissingArgumentException("Can't handle URI because agent ${agent.name} for workspace $workspaceName from $deploymentURL is not ready"))
return
}
val cli = ensureCLI(
context,
deploymentURL.toURL(),
restClient.buildInfo().version
)
// We only need to log in if we are using token-based auth.
if (restClient.token != null) {
context.logger.info("Authenticating Coder CLI...")
cli.login(restClient.token)
}
context.logger.info("Configuring Coder CLI...")
cli.configSsh(restClient.agentNames(workspaces))
if (shouldWaitForAutoLogin) {
isInitialized.waitForTrue()
}
reInitialize(restClient, cli)
val environmentId = "${workspace.name}.${agent.name}"
context.popupPluginMainPage()
context.envPageManager.showEnvironmentPage(environmentId, false)
val productCode = params.ideProductCode()
val buildNumber = params.ideBuildNumber()
val projectFolder = params.projectFolder()
if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) {
context.cs.launch {
val ideVersion = "$productCode-$buildNumber"
context.logger.info("installing $ideVersion on $environmentId")
val job = context.cs.launch {
context.ideOrchestrator.prepareClient(environmentId, ideVersion)
}
job.join()
context.logger.info("launching $ideVersion on $environmentId")
context.ideOrchestrator.connectToIde(environmentId, ideVersion, projectFolder)
}
}
}
private suspend fun CoderRestClient.waitForReady(workspace: Workspace): Boolean {
var status = workspace.latestBuild.status
try {
withTimeout(2.minutes.toJavaDuration()) {
while (status != WorkspaceStatus.RUNNING) {
delay(1.seconds)
status = [email protected](workspace.id).latestBuild.status
}
}
return true
} catch (_: TimeoutCancellationException) {
return false
}
}
private suspend fun askUrl(): String? {
context.popupPluginMainPage()
return dialogUi.ask(
context.i18n.ptrl("Deployment URL"),
context.i18n.ptrl("Enter the full URL of your Coder deployment")
)
}
/**
* Return an authenticated Coder CLI, asking for the token.
* Throw MissingArgumentException if the user aborts. Any network or invalid
* token error may also be thrown.
*/
private suspend fun authenticate(
deploymentURL: String,
tryToken: String?
): CoderRestClient {
val token =
if (settings.requireTokenAuth) {
// Try the provided token immediately on the first attempt.
if (!tryToken.isNullOrBlank()) {
tryToken
} else {
context.popupPluginMainPage()
// Otherwise ask for a new token, showing the previous token.
dialogUi.askToken(deploymentURL.toURL())
}
} else {
null
}
if (settings.requireTokenAuth && token == null) { // User aborted.
throw MissingArgumentException("Token is required")
}
val client = CoderRestClient(
context,
deploymentURL.toURL(),
token,
PluginManager.pluginInfo.version
)
client.authenticate()
return client
}
}
/**
* Follow a URL's redirects to its final destination.
*/
internal 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")
}
/**
* Return the agent matching the provided agent ID or name in the parameters.
*
* @throws [IllegalArgumentException]
*/
internal fun getMatchingAgent(
parameters: Map<String, String?>,
workspace: Workspace,
): WorkspaceAgent {
val agents = workspace.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! }
if (agents.isEmpty()) {
throw IllegalArgumentException("The workspace \"${workspace.name}\" has no agents")
}
// If the agent is missing and the workspace has only one, use that.
// Prefer the ID over the name if both are set.
val agent =
if (!parameters.agentID().isNullOrBlank()) {
agents.firstOrNull { it.id.toString() == parameters.agentID() }
} else if (agents.size == 1) {
agents.first()
} else {
null
}
if (agent == null) {
if (!parameters.agentID().isNullOrBlank()) {
throw IllegalArgumentException("The workspace \"${workspace.name}\" does not have an agent with ID \"${parameters.agentID()}\"")
} else {
throw MissingArgumentException(
"Unable to determine which agent to connect to; \"$AGENT_ID\" must be set because the workspace \"${workspace.name}\" has more than one agent",
)
}
}
return agent
}
private suspend fun CoderToolboxContext.showErrorPopup(error: Throwable) {
popupPluginMainPage()
this.ui.showErrorInfoPopup(error)
}
private suspend fun CoderToolboxContext.showInfoPopup(
title: LocalizableString,
message: LocalizableString,
okLabel: LocalizableString
) {
popupPluginMainPage()
this.ui.showInfoPopup(title, message, okLabel)
}
private fun CoderToolboxContext.popupPluginMainPage() {
this.ui.showWindow()
this.envPageManager.showPluginEnvironmentsPage(true)
}
/**
* Suspends the coroutine until first true value is received.
*/
suspend fun StateFlow<Boolean>.waitForTrue() = this.first { it }
class MissingArgumentException(message: String, ex: Throwable? = null) : IllegalArgumentException(message, ex)