Skip to content

Commit 91269d1

Browse files
author
Rival Abdrakhmanov
committed
Add an azure-intellij-plugin-cloud-shell module
1 parent 5134a19 commit 91269d1

36 files changed

+1675
-1
lines changed

PluginsAndFeatures/azure-toolkit-for-intellij/.idea/gradle.xml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
plugins {
2+
alias(libs.plugins.serialization)
3+
}
4+
5+
dependencies {
6+
implementation("org.java-websocket:Java-WebSocket:1.5.1")
7+
implementation("com.microsoft.azure:azure-toolkit-ide-common-lib")
8+
9+
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
10+
11+
implementation("io.ktor:ktor-client-core:2.3.12") {
12+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core")
13+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core-jvm")
14+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-jdk8")
15+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-slf4j")
16+
}
17+
implementation("io.ktor:ktor-client-cio:2.3.12") {
18+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core")
19+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-jdk8")
20+
}
21+
implementation("io.ktor:ktor-client-content-negotiation:2.3.12") {
22+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core")
23+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-jdk8")
24+
}
25+
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.12") {
26+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core")
27+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-jdk8")
28+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-serialization-json")
29+
}
30+
implementation("io.ktor:ktor-client-auth:2.3.12") {
31+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core")
32+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-jdk8")
33+
}
34+
implementation("io.ktor:ktor-client-websockets:2.3.12") {
35+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core")
36+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-jdk8")
37+
}
38+
39+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.8.0") {
40+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core")
41+
}
42+
43+
intellijPlatform {
44+
bundledPlugin("org.jetbrains.plugins.terminal")
45+
}
46+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright 2018-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the MIT license.
3+
*/
4+
5+
package com.microsoft.azure.toolkit.intellij.cloudshell
6+
7+
import com.azure.core.credential.TokenRequestContext
8+
import com.azure.identity.implementation.util.ScopeUtil
9+
import com.intellij.openapi.components.Service
10+
import com.intellij.openapi.components.service
11+
import com.intellij.openapi.diagnostic.logger
12+
import com.microsoft.azure.toolkit.lib.Azure
13+
import com.microsoft.azure.toolkit.lib.auth.Account
14+
import com.microsoft.azure.toolkit.lib.auth.AzureAccount
15+
import kotlinx.coroutines.reactor.awaitSingleOrNull
16+
17+
@Service
18+
class CloudShellAccessTokenService {
19+
companion object {
20+
fun getInstance(): CloudShellAccessTokenService = service()
21+
private val LOG = logger<CloudShellAccessTokenService>()
22+
}
23+
24+
suspend fun getAccessToken(tenantId: String, azAccount: Account? = null): String? {
25+
val account = azAccount ?: Azure.az(AzureAccount::class.java).account()
26+
if (!account.isLoggedIn) {
27+
LOG.warn("Account is not logged in")
28+
return null
29+
}
30+
31+
val managementEndpoint = account.environment.managementEndpoint
32+
val scopes = ScopeUtil.resourceToScopes(managementEndpoint)
33+
val request = TokenRequestContext().apply { addScopes(*scopes) }
34+
val accessToken = account
35+
.getTenantTokenCredential(tenantId)
36+
.getToken(request)
37+
.awaitSingleOrNull()
38+
?.token
39+
40+
if (accessToken == null) {
41+
LOG.warn("Unable to get access token")
42+
return null
43+
}
44+
45+
return accessToken
46+
}
47+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/*
2+
* Copyright 2018-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the MIT license.
3+
*/
4+
5+
package com.microsoft.azure.toolkit.intellij.cloudshell
6+
7+
import com.azure.core.credential.TokenRequestContext
8+
import com.azure.identity.implementation.util.ScopeUtil
9+
import com.intellij.notification.Notification
10+
import com.intellij.notification.NotificationType
11+
import com.intellij.openapi.application.EDT
12+
import com.intellij.openapi.components.Service
13+
import com.intellij.openapi.components.service
14+
import com.intellij.openapi.diagnostic.logger
15+
import com.intellij.openapi.project.Project
16+
import com.intellij.openapi.wm.ToolWindowManager
17+
import com.intellij.platform.ide.progress.withBackgroundProgress
18+
import com.intellij.platform.util.progress.withProgressText
19+
import com.microsoft.azure.toolkit.intellij.cloudshell.rest.*
20+
import com.microsoft.azure.toolkit.intellij.cloudshell.terminal.AzureCloudTerminalFactory
21+
import com.microsoft.azure.toolkit.lib.Azure
22+
import com.microsoft.azure.toolkit.lib.auth.Account
23+
import com.microsoft.azure.toolkit.lib.auth.AzureAccount
24+
import com.microsoft.azure.toolkit.lib.common.model.Subscription
25+
import kotlinx.coroutines.Dispatchers
26+
import kotlinx.coroutines.delay
27+
import kotlinx.coroutines.reactor.awaitSingleOrNull
28+
import kotlinx.coroutines.withContext
29+
import org.jetbrains.plugins.terminal.TerminalToolWindowFactory
30+
import org.jetbrains.plugins.terminal.TerminalToolWindowManager
31+
import java.net.URI
32+
import kotlin.time.Duration.Companion.milliseconds
33+
34+
@Service(Service.Level.PROJECT)
35+
class CloudShellProvisioningService(private val project: Project) {
36+
companion object {
37+
fun getInstance(project: Project) = project.service<CloudShellProvisioningService>()
38+
private val LOG = logger<CloudShellProvisioningService>()
39+
}
40+
41+
private val defaultTerminalColumns = 100
42+
private val defaultTerminalRows = 30
43+
44+
suspend fun provision(subscription: Subscription) = withBackgroundProgress(project, "Provisioning cloud shell...") {
45+
try {
46+
val account = Azure.az(AzureAccount::class.java).account()
47+
if (!account.isLoggedIn) {
48+
showUnsuccessfulNotification("Account is not logged in.")
49+
return@withBackgroundProgress
50+
}
51+
52+
val accessTokenResult = setAccessToken(account, subscription.tenantId)
53+
if (!accessTokenResult) return@withBackgroundProgress
54+
55+
val resourceManagerEndpoint = account.environment.resourceManagerEndpoint
56+
57+
val settings = getSettings(resourceManagerEndpoint)
58+
if (settings == null) {
59+
showUnsuccessfulNotification("Unable to retrieve cloud shell settings.")
60+
return@withBackgroundProgress
61+
}
62+
63+
val provisionResult = provision(resourceManagerEndpoint, settings)
64+
if (provisionResult == null) {
65+
showUnsuccessfulNotification("Unable to provision cloud shell.")
66+
return@withBackgroundProgress
67+
}
68+
69+
val provisionUrl = provisionResult.properties.uri
70+
if (provisionUrl.isNullOrEmpty()) {
71+
LOG.error("Cloud shell URL was empty")
72+
return@withBackgroundProgress
73+
}
74+
75+
val provisionTerminalResult = provisionTerminal(provisionUrl, account, subscription.tenantId)
76+
if (provisionTerminalResult == null) {
77+
showUnsuccessfulNotification("Unable to provision cloud shell terminal.")
78+
return@withBackgroundProgress
79+
}
80+
81+
val socketUri = provisionTerminalResult.socketUri?.trimEnd('/')
82+
if (socketUri.isNullOrEmpty()) {
83+
LOG.error("Socket URI was empty")
84+
return@withBackgroundProgress
85+
}
86+
87+
connectShellToTheTerminal(provisionUrl, socketUri)
88+
89+
} catch (t: Throwable) {
90+
LOG.error("Unable to provision cloud shell", t)
91+
showUnsuccessfulNotification("")
92+
}
93+
}
94+
95+
private suspend fun setAccessToken(account: Account, tenantId: String): Boolean {
96+
val accessToken = CloudShellAccessTokenService.getInstance().getAccessToken(tenantId, account)
97+
if (accessToken == null) {
98+
LOG.warn("Unable to get access token")
99+
return false
100+
}
101+
102+
val cloudConsoleService = CloudConsoleService.getInstance(project)
103+
cloudConsoleService.setAccessToken(accessToken, tenantId)
104+
105+
return true
106+
}
107+
108+
private suspend fun getSettings(resourceManagerEndpoint: String): CloudConsoleUserSettings? {
109+
val cloudConsoleService = CloudConsoleService.getInstance(project)
110+
val settings = withProgressText("Retrieving cloud shell preferences...") {
111+
cloudConsoleService.getUserSettings(resourceManagerEndpoint)
112+
}
113+
if (settings == null) {
114+
LOG.warn("Azure Cloud Shell is not configured in any Azure subscription")
115+
return null
116+
}
117+
118+
return settings
119+
}
120+
121+
private suspend fun provision(
122+
resourceManagerEndpoint: String,
123+
settings: CloudConsoleUserSettings
124+
): CloudConsoleProvisionResult? {
125+
val cloudConsoleService = CloudConsoleService.getInstance(project)
126+
val preferredOsType = settings.properties?.preferredOsType ?: "Linux"
127+
val provisionParameters = CloudConsoleProvisionParameters(
128+
CloudConsoleProvisionParameterProperties(preferredOsType)
129+
)
130+
val provisionResult = withProgressText("Provisioning cloud shell...") {
131+
cloudConsoleService.provision(
132+
resourceManagerEndpoint,
133+
settings.properties?.preferredLocation,
134+
provisionParameters
135+
)
136+
}
137+
138+
if (provisionResult == null || provisionResult.properties.provisioningState != "Succeeded") {
139+
LOG.warn("Unable to provision cloud shell")
140+
return null
141+
}
142+
143+
return provisionResult
144+
}
145+
146+
private suspend fun provisionTerminal(
147+
provisionUrl: String,
148+
account: Account,
149+
tenantId: String
150+
): CloudConsoleProvisionTerminalResult? {
151+
val shellUrl = "$provisionUrl/terminals"
152+
153+
val keyVaultDnsSuffix = "https://" + account.environment.keyVaultDnsSuffix.trimStart('.') + "/"
154+
val vaultScopes = ScopeUtil.resourceToScopes(keyVaultDnsSuffix)
155+
val vaultToken = account
156+
.getTenantTokenCredential(tenantId)
157+
.getToken(TokenRequestContext().apply { addScopes(*vaultScopes) })
158+
.awaitSingleOrNull()
159+
?.token
160+
161+
if (vaultToken == null) {
162+
LOG.warn("Unable to obtain a vault token")
163+
return null
164+
}
165+
166+
val shellUri = URI(shellUrl)
167+
val referrer = "${shellUri.scheme}://${shellUri.host}/\$hc${shellUri.path}"
168+
val provisionTerminalParameters = CloudConsoleProvisionTerminalParameters(
169+
listOf(vaultToken)
170+
)
171+
val cloudConsoleService = CloudConsoleService.getInstance(project)
172+
val provisionTerminalResult = withProgressText("Requesting cloud shell terminal...") {
173+
cloudConsoleService.provisionTerminal(
174+
shellUrl,
175+
defaultTerminalColumns,
176+
defaultTerminalRows,
177+
referrer,
178+
provisionTerminalParameters,
179+
)
180+
}
181+
if (provisionTerminalResult == null) {
182+
LOG.warn("Unable to provision cloud shell terminal")
183+
return null
184+
}
185+
186+
return provisionTerminalResult
187+
}
188+
189+
private suspend fun connectShellToTheTerminal(provisionUrl: String, socketUri: String) =
190+
withProgressText("Connecting to cloud shell terminal...") {
191+
val runner = AzureCloudTerminalFactory
192+
.getInstance(project)
193+
.createTerminalRunner(provisionUrl, socketUri)
194+
195+
val terminalWindow = ToolWindowManager.getInstance(project)
196+
.getToolWindow(TerminalToolWindowFactory.TOOL_WINDOW_ID)
197+
?: return@withProgressText
198+
199+
withContext(Dispatchers.EDT) {
200+
terminalWindow.show()
201+
202+
delay(500.milliseconds)
203+
204+
TerminalToolWindowManager
205+
.getInstance(project).createNewSession(runner)
206+
}
207+
}
208+
209+
private suspend fun showUnsuccessfulNotification(message: String) = withContext(Dispatchers.EDT) {
210+
Notification(
211+
"Azure CloudShell",
212+
"Unable to provision cloud shell",
213+
message,
214+
NotificationType.WARNING
215+
)
216+
.notify(project)
217+
}
218+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2018-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the MIT license.
3+
*/
4+
5+
package com.microsoft.azure.toolkit.intellij.cloudshell
6+
7+
import com.intellij.openapi.Disposable
8+
import com.intellij.openapi.components.Service
9+
import com.intellij.openapi.components.service
10+
import com.intellij.openapi.project.Project
11+
import com.microsoft.azure.toolkit.intellij.cloudshell.terminal.AzureCloudProcessTtyConnector
12+
13+
@Service(Service.Level.PROJECT)
14+
class CloudShellService: Disposable {
15+
companion object {
16+
fun getInstance(project: Project) = project.service<CloudShellService>()
17+
}
18+
19+
private val connectors = mutableListOf<AzureCloudProcessTtyConnector>()
20+
21+
fun registerConnector(connector: AzureCloudProcessTtyConnector) {
22+
connectors.add(connector)
23+
}
24+
25+
fun unregisterConnector(connector: AzureCloudProcessTtyConnector) {
26+
connectors.remove(connector)
27+
}
28+
29+
fun activeConnector() = connectors.firstOrNull { it.isConnected }
30+
31+
override fun dispose() {
32+
}
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright 2018-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the MIT license.
3+
*/
4+
5+
@file:Suppress("UnstableApiUsage")
6+
7+
package com.microsoft.azure.toolkit.intellij.cloudshell.actions
8+
9+
import com.intellij.openapi.actionSystem.ActionUpdateThread
10+
import com.intellij.openapi.actionSystem.AnAction
11+
import com.intellij.openapi.actionSystem.AnActionEvent
12+
import com.intellij.openapi.progress.currentThreadCoroutineScope
13+
import com.intellij.platform.ide.progress.withBackgroundProgress
14+
import com.microsoft.azure.toolkit.intellij.cloudshell.CloudShellService
15+
import kotlinx.coroutines.launch
16+
17+
class CloseAllCloudShellPortsAction : AnAction("Close all") {
18+
override fun update(e: AnActionEvent) {
19+
e.presentation.isEnabled = true
20+
}
21+
22+
override fun actionPerformed(e: AnActionEvent) {
23+
val project = e.project ?: return
24+
val activeConnector = CloudShellService.getInstance(project).activeConnector() ?: return
25+
26+
currentThreadCoroutineScope().launch {
27+
withBackgroundProgress(project, "Closing all preview ports in Azure Cloud Shell...") {
28+
activeConnector.closePreviewPorts()
29+
}
30+
}
31+
}
32+
33+
override fun getActionUpdateThread() = ActionUpdateThread.BGT
34+
}

0 commit comments

Comments
 (0)