Skip to content

Commit

Permalink
Auto-forward all workspace open ports when using Latest JetBrains IDEs
Browse files Browse the repository at this point in the history
  • Loading branch information
felladrin committed Aug 9, 2022
1 parent 5a88807 commit 3e940b8
Show file tree
Hide file tree
Showing 9 changed files with 331 additions and 60 deletions.
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.7.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") {
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()

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)
.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)

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

var address = localhostMatch.group(1)
if (address.startsWith('[') && address.endsWith(']')) {
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,136 @@
// 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) {
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 { handlePortStatusResponse(response) }
}
override fun onCompleted() { completableFuture.complete(null) }
override fun onError(throwable: Throwable) { completableFuture.completeExceptionally(throwable) }
}

statusServiceStub.portsStatus(portsStatusRequest, portsStatusResponseObserver)

return completableFuture
}

private fun handlePortStatusResponse(response: Status.PortsStatusResponse) {
val portNumberToServedStatusMap = mutableMapOf<Int, Boolean>()

for (port in response.portsList) {
portNumberToServedStatusMap[port.localPort] = port.served
}

updateForwardedPortsList(portNumberToServedStatusMap)
}

private fun updateForwardedPortsList(portNumberToServedStatusMap: Map<Int, Boolean>) {
val portForwardingManager = PortForwardingManager.getInstance(project)
val forwardedPortsList = portForwardingManager.getForwardedPortsWithLabel(FORWARDED_PORT_LABEL)

portNumberToServedStatusMap.forEach { (hostPort, isServed) ->
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)
}

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

0 comments on commit 3e940b8

Please sign in to comment.