Skip to content

Commit 1ca27f2

Browse files
felladrinAndrea Falzetti
authored and
Andrea Falzetti
committed
Auto-forward all workspace open ports when using Latest JetBrains IDEs
1 parent 7fd0492 commit 1ca27f2

File tree

12 files changed

+333
-64
lines changed

12 files changed

+333
-64
lines changed

components/ide/jetbrains/backend-plugin/build.gradle.kts

+4-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ plugins {
1313
// Kotlin support - check the latest version at https://plugins.gradle.org/plugin/org.jetbrains.kotlin.jvm
1414
id("org.jetbrains.kotlin.jvm") version "1.7.0"
1515
// gradle-intellij-plugin - read more: https://github.com/JetBrains/gradle-intellij-plugin
16-
id("org.jetbrains.intellij") version "1.6.0"
16+
id("org.jetbrains.intellij") version "1.7.0"
1717
// detekt linter - read more: https://detekt.github.io/detekt/gradle.html
1818
id("io.gitlab.arturbosch.detekt") version "1.17.1"
1919
// ktlint linter - read more: https://github.com/JLLeitschuh/ktlint-gradle
@@ -108,7 +108,9 @@ tasks {
108108
}
109109

110110
test {
111-
useJUnitPlatform()
111+
// https://youtrack.jetbrains.com/issue/IDEA-278926/All-inheritors-of-UsefulTestCase-are-invisible-for-Gradle#focus=Comments-27-5561012.0-0
112+
isScanForTestClasses = false
113+
include("**/*Test.class")
112114
}
113115

114116
runPluginVerifier {

components/ide/jetbrains/backend-plugin/src/main/kotlin/io/gitpod/jetbrains/remote/GitpodCLIService.kt

+23-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import com.intellij.openapi.project.Project
1616
import com.intellij.openapi.util.io.BufferExposingByteArrayOutputStream
1717
import com.intellij.openapi.util.io.FileUtilRt
1818
import com.intellij.util.application
19+
import com.intellij.util.withFragment
20+
import com.intellij.util.withPath
21+
import com.intellij.util.withQuery
22+
import com.jetbrains.rd.util.URI
1923
import io.netty.buffer.Unpooled
2024
import io.netty.channel.ChannelHandlerContext
2125
import io.netty.handler.codec.http.FullHttpRequest
@@ -31,6 +35,7 @@ import java.nio.file.Path
3135
class GitpodCLIService : RestService() {
3236

3337
private val manager = service<GitpodManager>()
38+
private val portsService = service<GitpodPortsService>()
3439

3540
override fun getServiceName() = SERVICE_NAME
3641

@@ -68,13 +73,30 @@ class GitpodCLIService : RestService() {
6873
if (url.isNullOrBlank()) {
6974
return "url is missing"
7075
}
76+
77+
val resolvedUrl = resolveExternalUrl(url)
78+
7179
return withClient(request, context) { project ->
72-
BrowserUtil.browse(url, project)
80+
BrowserUtil.browse(resolvedUrl, project)
7381
}
7482
}
7583
return "invalid operation"
7684
}
7785

86+
private fun resolveExternalUrl(url: String): String {
87+
val uri = URI.create(url)
88+
val optionalLocalHostUriMetadata = portsService.extractLocalHostUriMetaDataForPortMapping(uri)
89+
90+
return when {
91+
optionalLocalHostUriMetadata.isEmpty -> url
92+
else -> portsService.getLocalHostUriFromHostPort(optionalLocalHostUriMetadata.get().port)
93+
.withPath(uri.path)
94+
.withQuery(uri.query)
95+
.withFragment(uri.fragment)
96+
.toString()
97+
}
98+
}
99+
78100
private fun withClient(request: FullHttpRequest, context: ChannelHandlerContext, action: (project: Project?) -> Unit): String? {
79101
ApplicationManager.getApplication().executeOnPooledThread {
80102
getClientSessionAndProjectAsync().let { (session, project) ->

components/ide/jetbrains/backend-plugin/src/main/kotlin/io/gitpod/jetbrains/remote/GitpodClientProjectSessionTracker.kt

+16-7
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,13 @@ import org.jetbrains.ide.BuiltInServerManager
3333
import java.util.concurrent.CancellationException
3434
import java.util.concurrent.CompletableFuture
3535

36+
@Suppress("UnstableApiUsage", "OPT_IN_USAGE")
3637
class GitpodClientProjectSessionTracker(
3738
private val session: ClientProjectSession
3839
) : Disposable {
3940

4041
private val manager = service<GitpodManager>()
42+
private val portsService = service<GitpodPortsService>()
4143

4244
private lateinit var info: Info.WorkspaceInfoResponse
4345
private val lifetime = Lifetime.Eternal.createNested()
@@ -54,19 +56,26 @@ class GitpodClientProjectSessionTracker(
5456
}
5557
}
5658

57-
private fun isExposedServedPort(port: Status.PortsStatus?): Boolean {
59+
private fun isExposedServedPort(port: PortsStatus?): Boolean {
5860
if (port === null) {
5961
return false
6062
}
6163
return port.served && port.hasExposed()
6264
}
6365

66+
private fun getForwardedPortUrl(port: PortsStatus): String {
67+
return when {
68+
portsService.isForwarded(port.localPort) -> portsService.getLocalHostUriFromHostPort(port.localPort).toString()
69+
else -> port.exposed.url
70+
}
71+
}
72+
6473
private fun showOpenServiceNotification(port: PortsStatus, offerMakePublic: Boolean = false) {
6574
val message = "A service is available on port ${port.localPort}"
6675
val notification = manager.notificationGroup.createNotification(message, NotificationType.INFORMATION)
6776

68-
val openBrowserAction = NotificationAction.createSimple("Open Browser") {
69-
openBrowser(port.exposed.url)
77+
val openBrowserAction = NotificationAction.createSimple("Open browser") {
78+
openBrowser(getForwardedPortUrl(port))
7079
}
7180
notification.addAction(openBrowserAction)
7281

@@ -76,7 +85,7 @@ class GitpodClientProjectSessionTracker(
7685
makePortPublic(info.workspaceId, port)
7786
}
7887
}
79-
val makePublicAction = NotificationAction.createSimple("Make Public", makePublicLambda)
88+
val makePublicAction = NotificationAction.createSimple("Make public", makePublicLambda)
8089
notification.addAction(makePublicAction)
8190
}
8291

@@ -113,7 +122,7 @@ class GitpodClientProjectSessionTracker(
113122
val backendPort = BuiltInServerManager.getInstance().waitForStart().port
114123
val serverPort = StartupUtil.getServerFuture().await().port
115124
val ignorePorts = listOf(backendPort, serverPort, 5990)
116-
val portsStatus = hashMapOf<Int, Status.PortsStatus>()
125+
val portsStatus = hashMapOf<Int, PortsStatus>()
117126

118127
val status = StatusServiceGrpc.newStub(GitpodManager.supervisorChannel)
119128
while (isActive) {
@@ -147,7 +156,7 @@ class GitpodClientProjectSessionTracker(
147156
}
148157

149158
if (port.exposed.onExposed.number == Status.OnPortExposedAction.open_browser_VALUE || port.exposed.onExposed.number == Status.OnPortExposedAction.open_preview_VALUE) {
150-
openBrowser(port.exposed.url)
159+
openBrowser(getForwardedPortUrl(port))
151160
continue
152161
}
153162

@@ -157,7 +166,7 @@ class GitpodClientProjectSessionTracker(
157166
}
158167

159168
if (port.exposed.onExposed.number == Status.OnPortExposedAction.notify_private_VALUE) {
160-
showOpenServiceNotification(port, port.exposed.visibilityValue !== PortVisibility.public_visibility_VALUE)
169+
showOpenServiceNotification(port, port.exposed.visibilityValue != PortVisibility.public_visibility_VALUE)
161170
continue
162171
}
163172
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License-AGPL.txt in the project root for license information.
4+
5+
package io.gitpod.jetbrains.remote
6+
7+
import com.intellij.openapi.diagnostic.thisLogger
8+
import com.jetbrains.rd.util.URI
9+
import org.apache.http.client.utils.URIBuilder
10+
import java.util.Optional
11+
import java.util.regex.Pattern
12+
13+
class GitpodPortsService {
14+
companion object {
15+
/** Host used by forwarded ports on JetBrains Client. */
16+
const val FORWARDED_PORT_HOST = "127.0.0.1"
17+
}
18+
private val hostToClientForwardedPortMap: MutableMap<Int, Int> = mutableMapOf()
19+
20+
fun isForwarded(hostPort: Int): Boolean = hostToClientForwardedPortMap.containsKey(hostPort)
21+
22+
private fun getForwardedPort(hostPort: Int): Optional<Int> = Optional.ofNullable(hostToClientForwardedPortMap[hostPort])
23+
24+
fun setForwardedPort(hostPort: Int, clientPort: Int) {
25+
hostToClientForwardedPortMap[hostPort] = clientPort
26+
}
27+
28+
fun removeForwardedPort(hostPort: Int) {
29+
hostToClientForwardedPortMap.remove(hostPort)
30+
}
31+
32+
fun getLocalHostUriFromHostPort(hostPort: Int): URI {
33+
val optionalForwardedPort = getForwardedPort(hostPort)
34+
35+
val port = if (optionalForwardedPort.isPresent) {
36+
optionalForwardedPort.get()
37+
} else {
38+
thisLogger().warn(
39+
"gitpod: Tried to get the forwarded port of $hostPort, which was not forwarded. " +
40+
"Returning $hostPort itself."
41+
)
42+
hostPort
43+
}
44+
45+
return URIBuilder()
46+
.setScheme("http")
47+
.setHost(FORWARDED_PORT_HOST)
48+
.setPort(port)
49+
.build()
50+
}
51+
52+
interface LocalHostUriMetadata {
53+
val address: String
54+
val port: Int
55+
}
56+
57+
fun extractLocalHostUriMetaDataForPortMapping(uri: URI): Optional<LocalHostUriMetadata> {
58+
if (uri.scheme != "http" && uri.scheme != "https") return Optional.empty()
59+
60+
val localhostMatch = Pattern.compile("^(localhost|127(?:\\.[0-9]+){0,2}\\.[0-9]+|0+(?:\\.0+){0,2}\\.0+|\\[(?:0*:)*?:?0*1?])(?::(\\d+))?\$").matcher(uri.authority)
61+
62+
if (!localhostMatch.find()) return Optional.empty()
63+
64+
var address = localhostMatch.group(1)
65+
if (address.startsWith('[') && address.endsWith(']')) {
66+
address = address.substring(1, address.length - 2)
67+
}
68+
69+
var port = 443
70+
try {
71+
port = localhostMatch.group(2).toInt()
72+
} catch (throwable: Throwable){
73+
if (uri.scheme == "http") port = 80
74+
}
75+
76+
return Optional.of(object: LocalHostUriMetadata {
77+
override val address = address
78+
override val port = port
79+
})
80+
}
81+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License-AGPL.txt in the project root for license information.
4+
5+
package io.gitpod.jetbrains.remote.latest
6+
7+
import com.intellij.openapi.components.service
8+
import com.intellij.openapi.diagnostic.thisLogger
9+
import com.intellij.openapi.project.Project
10+
import com.intellij.remoteDev.util.onTerminationOrNow
11+
import com.intellij.util.application
12+
import com.jetbrains.codeWithMe.model.RdPortType
13+
import com.jetbrains.rd.platform.util.lifetime
14+
import com.jetbrains.rd.util.lifetime.LifetimeStatus
15+
import com.jetbrains.rdserver.portForwarding.ForwardedPortInfo
16+
import com.jetbrains.rdserver.portForwarding.PortForwardingManager
17+
import com.jetbrains.rdserver.portForwarding.remoteDev.PortEventsProcessor
18+
import io.gitpod.jetbrains.remote.GitpodManager
19+
import io.gitpod.jetbrains.remote.GitpodPortsService
20+
import io.gitpod.supervisor.api.Status
21+
import io.gitpod.supervisor.api.StatusServiceGrpc
22+
import io.grpc.stub.ClientCallStreamObserver
23+
import io.grpc.stub.ClientResponseObserver
24+
import io.ktor.utils.io.*
25+
import java.util.concurrent.CompletableFuture
26+
import java.util.concurrent.TimeUnit
27+
28+
@Suppress("UnstableApiUsage")
29+
class GitpodPortForwardingService(private val project: Project) {
30+
companion object {
31+
const val FORWARDED_PORT_LABEL = "gitpod"
32+
}
33+
34+
private val portsService = service<GitpodPortsService>()
35+
36+
init { start() }
37+
38+
private fun start() {
39+
if (application.isHeadlessEnvironment) return
40+
41+
observePortsListWhileProjectIsOpen()
42+
}
43+
44+
private fun observePortsListWhileProjectIsOpen() = application.executeOnPooledThread {
45+
while (project.lifetime.status == LifetimeStatus.Alive) {
46+
try {
47+
observePortsList().get()
48+
} catch (throwable: Throwable) {
49+
when (throwable) {
50+
is InterruptedException, is CancellationException -> break
51+
else -> thisLogger().error(
52+
"gitpod: Got an error while trying to get ports list from Supervisor. " +
53+
"Going to try again in a second.",
54+
throwable
55+
)
56+
}
57+
}
58+
59+
TimeUnit.SECONDS.sleep(1)
60+
}
61+
}
62+
63+
private fun observePortsList(): CompletableFuture<Void> {
64+
val completableFuture = CompletableFuture<Void>()
65+
66+
val statusServiceStub = StatusServiceGrpc.newStub(GitpodManager.supervisorChannel)
67+
68+
val portsStatusRequest = Status.PortsStatusRequest.newBuilder().setObserve(true).build()
69+
70+
val portsStatusResponseObserver = object :
71+
ClientResponseObserver<Status.PortsStatusRequest, Status.PortsStatusResponse> {
72+
override fun beforeStart(request: ClientCallStreamObserver<Status.PortsStatusRequest>) {
73+
project.lifetime.onTerminationOrNow { request.cancel("gitpod: Project terminated.", null) }
74+
}
75+
override fun onNext(response: Status.PortsStatusResponse) { handlePortStatusResponse(response) }
76+
override fun onCompleted() { completableFuture.complete(null) }
77+
override fun onError(throwable: Throwable) { completableFuture.completeExceptionally(throwable) }
78+
}
79+
80+
statusServiceStub.portsStatus(portsStatusRequest, portsStatusResponseObserver)
81+
82+
return completableFuture
83+
}
84+
85+
private fun handlePortStatusResponse(response: Status.PortsStatusResponse) {
86+
val portNumberToServedStatusMap = mutableMapOf<Int, Boolean>()
87+
88+
for (port in response.portsList) {
89+
portNumberToServedStatusMap[port.localPort] = port.served
90+
}
91+
92+
updateForwardedPortsList(portNumberToServedStatusMap)
93+
}
94+
95+
private fun updateForwardedPortsList(portNumberToServedStatusMap: Map<Int, Boolean>) {
96+
val portForwardingManager = PortForwardingManager.getInstance(project)
97+
val forwardedPortsList = portForwardingManager.getForwardedPortsWithLabel(FORWARDED_PORT_LABEL)
98+
99+
portNumberToServedStatusMap.forEach { (hostPort, isServed) ->
100+
if (isServed && !forwardedPortsList.containsKey(hostPort)) {
101+
val portEventsProcessor = object : PortEventsProcessor {
102+
override fun onPortForwarded(hostPort: Int, clientPort: Int) {
103+
portsService.setForwardedPort(hostPort, clientPort)
104+
thisLogger().info("gitpod: Forwarded port $hostPort to client's port $clientPort.")
105+
}
106+
107+
override fun onPortForwardingEnded(hostPort: Int) {
108+
thisLogger().info("gitpod: Finished forwarding port $hostPort.")
109+
}
110+
111+
override fun onPortForwardingFailed(hostPort: Int, reason: String) {
112+
thisLogger().error("gitpod: Failed to forward port $hostPort: $reason")
113+
}
114+
}
115+
116+
val portInfo = ForwardedPortInfo(
117+
hostPort,
118+
RdPortType.HTTP,
119+
FORWARDED_PORT_LABEL,
120+
emptyList(),
121+
portEventsProcessor
122+
)
123+
124+
portForwardingManager.forwardPort(portInfo)
125+
}
126+
127+
if (!isServed && forwardedPortsList.containsKey(hostPort)) {
128+
portForwardingManager.removePort(hostPort)
129+
portsService.removeForwardedPort(hostPort)
130+
thisLogger().info("gitpod: Stopped forwarding port $hostPort.")
131+
}
132+
}
133+
}
134+
}

0 commit comments

Comments
 (0)