Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auto-forward all workspace open ports when using Latest JetBrains IDEs #11081

Merged
merged 1 commit into from
Aug 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions components/ide/jetbrains/backend-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ plugins {
// Kotlin support - check the latest version at https://plugins.gradle.org/plugin/org.jetbrains.kotlin.jvm
id("org.jetbrains.kotlin.jvm") version "1.7.0"
// gradle-intellij-plugin - read more: https://github.com/JetBrains/gradle-intellij-plugin
id("org.jetbrains.intellij") version "1.6.0"
id("org.jetbrains.intellij") version "1.8.0"
// detekt linter - read more: https://detekt.github.io/detekt/gradle.html
id("io.gitlab.arturbosch.detekt") version "1.17.1"
// ktlint linter - read more: https://github.com/JLLeitschuh/ktlint-gradle
Expand Down Expand Up @@ -108,7 +108,10 @@ tasks {
}

test {
useJUnitPlatform()
// Currently, we need to indicate where are the test classes.
// Read more: https://youtrack.jetbrains.com/issue/IDEA-278926/All-inheritors-of-UsefulTestCase-are-invisible-for-Gradle#focus=Comments-27-5561012.0-0
isScanForTestClasses = false
include("**/*Test.class")
}

runPluginVerifier {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import com.intellij.openapi.project.Project
import com.intellij.openapi.util.io.BufferExposingByteArrayOutputStream
import com.intellij.openapi.util.io.FileUtilRt
import com.intellij.util.application
import com.intellij.util.withFragment
import com.intellij.util.withPath
import com.intellij.util.withQuery
import com.jetbrains.rd.util.URI
import io.netty.buffer.Unpooled
import io.netty.channel.ChannelHandlerContext
import io.netty.handler.codec.http.FullHttpRequest
Expand All @@ -31,6 +35,7 @@ import java.nio.file.Path
class GitpodCLIService : RestService() {

private val manager = service<GitpodManager>()
private val portsService = service<GitpodPortsService>()

override fun getServiceName() = SERVICE_NAME

Expand Down Expand Up @@ -68,13 +73,30 @@ class GitpodCLIService : RestService() {
if (url.isNullOrBlank()) {
return "url is missing"
}

val resolvedUrl = resolveExternalUrl(url)

return withClient(request, context) { project ->
BrowserUtil.browse(url, project)
BrowserUtil.browse(resolvedUrl, project)
}
}
return "invalid operation"
}

private fun resolveExternalUrl(url: String): String {
val uri = URI.create(url)
val optionalLocalHostUriMetadata = portsService.extractLocalHostUriMetaDataForPortMapping(uri)

return when {
optionalLocalHostUriMetadata.isEmpty -> url
else -> portsService.getLocalHostUriFromHostPort(optionalLocalHostUriMetadata.get().port)
.withPath(uri.path)
.withQuery(uri.query)
.withFragment(uri.fragment)
.toString()
}
}

private fun withClient(request: FullHttpRequest, context: ChannelHandlerContext, action: (project: Project?) -> Unit): String? {
ApplicationManager.getApplication().executeOnPooledThread {
getClientSessionAndProjectAsync().let { (session, project) ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@ import org.jetbrains.ide.BuiltInServerManager
import java.util.concurrent.CancellationException
import java.util.concurrent.CompletableFuture

@Suppress("UnstableApiUsage", "OPT_IN_USAGE")
class GitpodClientProjectSessionTracker(
private val session: ClientProjectSession
) : Disposable {

private val manager = service<GitpodManager>()
private val portsService = service<GitpodPortsService>()

private lateinit var info: Info.WorkspaceInfoResponse
private val lifetime = Lifetime.Eternal.createNested()
Expand All @@ -54,19 +56,26 @@ class GitpodClientProjectSessionTracker(
}
}

private fun isExposedServedPort(port: Status.PortsStatus?): Boolean {
private fun isExposedServedPort(port: PortsStatus?): Boolean {
if (port === null) {
return false
}
return port.served && port.hasExposed()
}

private fun getForwardedPortUrl(port: PortsStatus): String {
return when {
portsService.isForwarded(port.localPort) -> portsService.getLocalHostUriFromHostPort(port.localPort).toString()
else -> port.exposed.url
}
}

private fun showOpenServiceNotification(port: PortsStatus, offerMakePublic: Boolean = false) {
val message = "A service is available on port ${port.localPort}"
val notification = manager.notificationGroup.createNotification(message, NotificationType.INFORMATION)

val openBrowserAction = NotificationAction.createSimple("Open Browser") {
openBrowser(port.exposed.url)
val openBrowserAction = NotificationAction.createSimple("Open browser") {
akosyakov marked this conversation as resolved.
Show resolved Hide resolved
openBrowser(getForwardedPortUrl(port))
}
notification.addAction(openBrowserAction)

Expand All @@ -76,7 +85,7 @@ class GitpodClientProjectSessionTracker(
makePortPublic(info.workspaceId, port)
}
}
val makePublicAction = NotificationAction.createSimple("Make Public", makePublicLambda)
val makePublicAction = NotificationAction.createSimple("Make public", makePublicLambda)
notification.addAction(makePublicAction)
}

Expand Down Expand Up @@ -113,7 +122,7 @@ class GitpodClientProjectSessionTracker(
val backendPort = BuiltInServerManager.getInstance().waitForStart().port
val serverPort = StartupUtil.getServerFuture().await().port
val ignorePorts = listOf(backendPort, serverPort, 5990)
val portsStatus = hashMapOf<Int, Status.PortsStatus>()
val portsStatus = hashMapOf<Int, PortsStatus>()

val status = StatusServiceGrpc.newStub(GitpodManager.supervisorChannel)
while (isActive) {
Expand Down Expand Up @@ -147,7 +156,7 @@ class GitpodClientProjectSessionTracker(
}

if (port.exposed.onExposed.number == Status.OnPortExposedAction.open_browser_VALUE || port.exposed.onExposed.number == Status.OnPortExposedAction.open_preview_VALUE) {
openBrowser(port.exposed.url)
openBrowser(getForwardedPortUrl(port))
continue
}

Expand All @@ -157,7 +166,7 @@ class GitpodClientProjectSessionTracker(
}

if (port.exposed.onExposed.number == Status.OnPortExposedAction.notify_private_VALUE) {
showOpenServiceNotification(port, port.exposed.visibilityValue !== PortVisibility.public_visibility_VALUE)
showOpenServiceNotification(port, port.exposed.visibilityValue != PortVisibility.public_visibility_VALUE)
continue
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License-AGPL.txt in the project root for license information.

package io.gitpod.jetbrains.remote

import com.intellij.openapi.diagnostic.thisLogger
import com.jetbrains.rd.util.URI
import org.apache.http.client.utils.URIBuilder
import java.util.Optional
import java.util.regex.Pattern

class GitpodPortsService {
companion object {
/** Host used by forwarded ports on JetBrains Client. */
const val FORWARDED_PORT_HOST = "127.0.0.1"
}
private val hostToClientForwardedPortMap: MutableMap<Int, Int> = mutableMapOf()
felladrin marked this conversation as resolved.
Show resolved Hide resolved

fun isForwarded(hostPort: Int): Boolean = hostToClientForwardedPortMap.containsKey(hostPort)

private fun getForwardedPort(hostPort: Int): Optional<Int> = Optional.ofNullable(hostToClientForwardedPortMap[hostPort])

fun setForwardedPort(hostPort: Int, clientPort: Int) {
hostToClientForwardedPortMap[hostPort] = clientPort
}

fun removeForwardedPort(hostPort: Int) {
hostToClientForwardedPortMap.remove(hostPort)
}

fun getLocalHostUriFromHostPort(hostPort: Int): URI {
val optionalForwardedPort = getForwardedPort(hostPort)

val port = if (optionalForwardedPort.isPresent) {
optionalForwardedPort.get()
} else {
thisLogger().warn(
"gitpod: Tried to get the forwarded port of $hostPort, which was not forwarded. " +
"Returning $hostPort itself."
)
hostPort
}

return URIBuilder()
.setScheme("http")
.setHost(FORWARDED_PORT_HOST)
felladrin marked this conversation as resolved.
Show resolved Hide resolved
.setPort(port)
.build()
}

interface LocalHostUriMetadata {
val address: String
val port: Int
}

fun extractLocalHostUriMetaDataForPortMapping(uri: URI): Optional<LocalHostUriMetadata> {
if (uri.scheme != "http" && uri.scheme != "https") return Optional.empty()

val localhostMatch = Pattern.compile("^(localhost|127(?:\\.[0-9]+){0,2}\\.[0-9]+|0+(?:\\.0+){0,2}\\.0+|\\[(?:0*:)*?:?0*1?])(?::(\\d+))?\$").matcher(uri.authority)
felladrin marked this conversation as resolved.
Show resolved Hide resolved

if (!localhostMatch.find()) return Optional.empty()

var address = localhostMatch.group(1)
if (address.startsWith('[') && address.endsWith(']')) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How could this happen😂

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: ipv6

Copy link
Contributor

@mustard-mh mustard-mh Aug 5, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see https://pl.kotl.in/ERWpCkfaW this case.

"http://[::1]:8080" is not covered? addr should be ::1 and port 8080

Copy link
Contributor Author

@felladrin felladrin Aug 5, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch!

I've added a unit test for it!

They can be run with ./gradlew test (when inside ide/jetbrains/backend-plugin folder) or via IDE UI:

image

ℹ️ This function is a translation of this JavaScript function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@akosyakov, that's a good question from @mustard-mh: why is it removing the last 2 characters instead of removing just the last one (]) here?

And wouldn't it be better to return the full hostname, like URL returns?
image

Currently the code is returning {address: "::", port: 8080} for http://[::1]:8080/.

Should we change it (in Kotlin code from this PR) to return "[::1]"?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember no, but looking at your client it does not seem to be matter. You don't use address anyway.

What you should understand here though, that the same port can be served on ip4 and ip6 at the same time as far as I remember then on client machine depending on the host there should be different ports too. I think it is worth to create a follow-up issue to consider it, and discuss with JB how to handle such cases. But it should NOT block this PR.

address = address.substring(1, address.length - 2)
}

var port = 443
try {
port = localhostMatch.group(2).toInt()
} catch (throwable: Throwable){
if (uri.scheme == "http") port = 80
}

return Optional.of(object: LocalHostUriMetadata {
override val address = address
override val port = port
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License-AGPL.txt in the project root for license information.

package io.gitpod.jetbrains.remote.latest

import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.project.Project
import com.intellij.remoteDev.util.onTerminationOrNow
import com.intellij.util.application
import com.jetbrains.codeWithMe.model.RdPortType
import com.jetbrains.rd.platform.util.lifetime
import com.jetbrains.rd.util.lifetime.LifetimeStatus
import com.jetbrains.rdserver.portForwarding.ForwardedPortInfo
import com.jetbrains.rdserver.portForwarding.PortForwardingManager
import com.jetbrains.rdserver.portForwarding.remoteDev.PortEventsProcessor
import io.gitpod.jetbrains.remote.GitpodManager
import io.gitpod.jetbrains.remote.GitpodPortsService
import io.gitpod.supervisor.api.Status
import io.gitpod.supervisor.api.StatusServiceGrpc
import io.grpc.stub.ClientCallStreamObserver
import io.grpc.stub.ClientResponseObserver
import io.ktor.utils.io.*
import java.util.concurrent.CompletableFuture
import java.util.concurrent.TimeUnit

@Suppress("UnstableApiUsage")
class GitpodPortForwardingService(private val project: Project) {
companion object {
const val FORWARDED_PORT_LABEL = "gitpod"
}

private val portsService = service<GitpodPortsService>()

init { start() }

private fun start() {
if (application.isHeadlessEnvironment) return

observePortsListWhileProjectIsOpen()
}

private fun observePortsListWhileProjectIsOpen() = application.executeOnPooledThread {
while (project.lifetime.status == LifetimeStatus.Alive) {
Copy link
Contributor

@mustard-mh mustard-mh Aug 5, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need while and sleep here since use observe from grpc below(.setObserve(true)). Just observe one time, and it'll keep response data.

See also the implement

func (s *statusService) PortsStatus(req *api.PortsStatusRequest, srv api.StatusService_PortsStatusServer) error {

Copy link
Contributor Author

@felladrin felladrin Aug 5, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, that while has a different function. It's there to cover the case where the user loses connection temporarily. So while the IDE is opened, it should keep trying to reconnect to Supervisor.

Do you think we can conver this case using only PortStatus?
But even so, we'd need to keep a cooldown before trying to connect again, so I think 1sec interval is ok.
We've been using this logic in all other classes (from JetBrains plugin) that requires waiting for reconnection.

try {
observePortsList().get()
} catch (throwable: Throwable) {
when (throwable) {
is InterruptedException, is CancellationException -> break
else -> thisLogger().error(
"gitpod: Got an error while trying to get ports list from Supervisor. " +
"Going to try again in a second.",
throwable
)
}
}

TimeUnit.SECONDS.sleep(1)
}
}

private fun observePortsList(): CompletableFuture<Void> {
val completableFuture = CompletableFuture<Void>()

val statusServiceStub = StatusServiceGrpc.newStub(GitpodManager.supervisorChannel)

val portsStatusRequest = Status.PortsStatusRequest.newBuilder().setObserve(true).build()

val portsStatusResponseObserver = object :
ClientResponseObserver<Status.PortsStatusRequest, Status.PortsStatusResponse> {
override fun beforeStart(request: ClientCallStreamObserver<Status.PortsStatusRequest>) {
project.lifetime.onTerminationOrNow { request.cancel("gitpod: Project terminated.", null) }
}
override fun onNext(response: Status.PortsStatusResponse) {
application.invokeLater { updateForwardedPortsList(response) }
}
override fun onCompleted() { completableFuture.complete(null) }
override fun onError(throwable: Throwable) { completableFuture.completeExceptionally(throwable) }
}

statusServiceStub.portsStatus(portsStatusRequest, portsStatusResponseObserver)

return completableFuture
}

private fun updateForwardedPortsList(response: Status.PortsStatusResponse) {
val portForwardingManager = PortForwardingManager.getInstance(project)
val forwardedPortsList = portForwardingManager.getForwardedPortsWithLabel(FORWARDED_PORT_LABEL)

for (port in response.portsList) {
val hostPort = port.localPort
val isServed = port.served

if (isServed && !forwardedPortsList.containsKey(hostPort)) {
val portEventsProcessor = object : PortEventsProcessor {
override fun onPortForwarded(hostPort: Int, clientPort: Int) {
portsService.setForwardedPort(hostPort, clientPort)
thisLogger().info("gitpod: Forwarded port $hostPort to client's port $clientPort.")
}

override fun onPortForwardingEnded(hostPort: Int) {
thisLogger().info("gitpod: Finished forwarding port $hostPort.")
}

override fun onPortForwardingFailed(hostPort: Int, reason: String) {
thisLogger().error("gitpod: Failed to forward port $hostPort: $reason")
}
}

val portInfo = ForwardedPortInfo(
hostPort,
RdPortType.HTTP,
FORWARDED_PORT_LABEL,
emptyList(),
portEventsProcessor
)

portForwardingManager.forwardPort(portInfo)
felladrin marked this conversation as resolved.
Show resolved Hide resolved
}

if (!isServed && forwardedPortsList.containsKey(hostPort)) {
portForwardingManager.removePort(hostPort)
portsService.removeForwardedPort(hostPort)
thisLogger().info("gitpod: Stopped forwarding port $hostPort.")
}
}
}
}
Loading