Skip to content

Commit

Permalink
[JBGateway] connection-based client side additional heartbeat (#20319)
Browse files Browse the repository at this point in the history
* [supervisor-api] Protobuf update and code generating

* [supervisor] implement `sendHeartbeat` method

* [JBGW] update jetbrains gateway (WIP)

* add thinClient a condition for additional heartbeat

* Update checkbox description
  • Loading branch information
mustard-mh authored Nov 5, 2024
1 parent 18b92d8 commit b6c243b
Show file tree
Hide file tree
Showing 15 changed files with 1,434 additions and 78 deletions.
46 changes: 46 additions & 0 deletions components/ide/jetbrains/gateway-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// See License.AGPL.txt in the project root for license information.

import io.gitlab.arturbosch.detekt.Detekt
import org.jetbrains.changelog.date
import org.jetbrains.changelog.markdownToHTML
import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
Expand Down Expand Up @@ -36,6 +37,8 @@ if (environmentName.isNotBlank()) {
pluginVersion += "-$environmentName"
}

pluginVersion = pluginVersion.replace("{{LOCAL_VERSION}}", date("MMddhhmm") + "-local")

project(":") {
kotlin {
val excludedPackage = if (environmentName == "latest") "stable" else "latest"
Expand Down Expand Up @@ -176,3 +179,46 @@ tasks {
}
}
}

tasks.register("installPlugin") {
group = "gitpod"

println("Building plugin $pluginVersion")

dependsOn("buildPlugin")

doLast {
val pluginTargetPath = "distributions/jetbrains-gateway-gitpod-plugin.zip"
val pluginFile = layout.buildDirectory.file(pluginTargetPath).orNull?.asFile ?: {
throw GradleException("Plugin file not found at $pluginTargetPath")
}

// Example for macOS ~/Library/Application Support/JetBrains/JetBrainsGateway2024.3/plugins
//
// JB_GATEWAY_PLUGINS_DIR=/Users/hwen/Library/Application Support/JetBrains/JetBrainsGateway2024.3/plugins
// JB_GATEWAY_IDEA_LOG_FILE=/Users/hwen/Library/Logs/JetBrains/JetBrainsGateway2024.3/idea.log
val gatewayPluginsDir = System.getenv("JB_GATEWAY_PLUGINS_DIR")
val gatewayIDEALogFile = System.getenv("JB_GATEWAY_IDEA_LOG_FILE")

if (gatewayPluginsDir.isNullOrEmpty()) {
throw GradleException("Found no JB_GATEWAY_PLUGINS_DIR environment variable")
}
println("Copying plugin from $pluginFile to $gatewayPluginsDir")

copy {
from(zipTree(pluginFile))
into(file(gatewayPluginsDir))
}

println("Plugin successfully copied to $gatewayPluginsDir")

exec {
commandLine("sh", "-c", "pkill -f 'Gateway' || true")
}
if (!gatewayIDEALogFile.isNullOrEmpty()) {
exec {
commandLine("sh", "-c", "echo '' > $gatewayIDEALogFile")
}
}
}
}
2 changes: 1 addition & 1 deletion components/ide/jetbrains/gateway-plugin/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ pluginName=gitpod-gateway
latestPluginName=Gitpod Gateway
pluginId=io.gitpod.jetbrains.gateway
# It is overriden by CI during the build.
pluginVersion=0.0.1
pluginVersion={{LOCAL_VERSION}}
# IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#intellij-extension-type
platformType=GW
platformDownloadSources=true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ import io.gitpod.jetbrains.gateway.common.GitpodConnectionHandleFactory
import io.gitpod.jetbrains.icons.GitpodIcons
import kotlinx.coroutines.*
import kotlinx.coroutines.future.await
import java.awt.Component
import java.net.URL
import java.net.http.HttpClient
import java.net.http.HttpRequest
Expand All @@ -58,6 +57,7 @@ import javax.swing.JLabel
import kotlin.coroutines.coroutineContext
import kotlin.io.path.absolutePathString
import kotlin.io.path.writeText
import kotlin.random.Random.Default.nextInt

@Suppress("UnstableApiUsage", "OPT_IN_USAGE")
class GitpodConnectionProvider : GatewayConnectionProvider {
Expand Down Expand Up @@ -202,6 +202,39 @@ class GitpodConnectionProvider : GatewayConnectionProvider {

var lastUpdate: WorkspaceInstance? = null
var canceledByGitpod = false

val ownerToken = client.server.getOwnerToken(connectParams.actualWorkspaceId).await()

if (settings.additionalHeartbeat) {
thisLogger().info("gitpod: additional heartbeat enabled for ${connectParams.resolvedWorkspaceId}")
connectionLifetime.launch {
while (isActive) {
val delaySeconds = 30 + nextInt(5, 15)
if (thinClientJob?.isActive == true) {
try {
val ideUrlStr = lastUpdate?.ideUrl
val ideUrl = if (ideUrlStr.isNullOrBlank()) {
null
} else {
URL(ideUrlStr.replace(connectParams.actualWorkspaceId, connectParams.resolvedWorkspaceId))
}
if (lastUpdate?.status?.phase == "running" && ideUrl != null) {
sendHeartBeatThroughSupervisor(ideUrl, ownerToken, connectParams)
}
} catch (t: Throwable) {
thisLogger().error(
"gitpod: failed to send additional heartbeat for ${connectParams.resolvedWorkspaceId}",
t
)
}
} else {
thisLogger().debug("gitpod: thinClient is not active, skipping additional heartbeat for ${connectParams.resolvedWorkspaceId}")
}
delay(delaySeconds * 1000L)
}
}
}

try {
for (update in updates) {
try {
Expand Down Expand Up @@ -518,7 +551,7 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
if (!connectParams.backendPort.isNullOrBlank()) {
resolveJoinLinkUrl += "?backendPort=${connectParams.backendPort}"
}
var rawResp = fetchWS(resolveJoinLinkUrl, connectParams, ownerToken)
var rawResp = retryFetchWS(resolveJoinLinkUrl, connectParams, ownerToken)
if (rawResp != null) {
return with(jacksonMapper) {
propertyNamingStrategy = PropertyNamingStrategies.LowerCamelCaseStrategy()
Expand All @@ -531,13 +564,34 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
if (!connectParams.backendPort.isNullOrBlank()) {
resolveJoinLinkUrl += "?backendPort=${connectParams.backendPort}"
}
rawResp = fetchWS(resolveJoinLinkUrl, connectParams, ownerToken)
rawResp = retryFetchWS(resolveJoinLinkUrl, connectParams, ownerToken)
if (rawResp != null) {
return JoinLinkResp(-1, rawResp)
}
return null
}

private var sendHeartBeatThroughSupervisorLogOnce = false
private suspend fun sendHeartBeatThroughSupervisor(
ideUrl: URL,
ownerToken: String,
connectParams: ConnectParams
) {
val resp = fetchWS("https://${ideUrl.host}/_supervisor/v1/send_heartbeat", ownerToken, 2000L)
if (resp.statusCode != 200) {
if (!resp.body.isNullOrBlank() && resp.body.contains("not implemented")) {
if (!sendHeartBeatThroughSupervisorLogOnce) {
thisLogger().warn("gitpod: sendHeartbeat ${connectParams.actualWorkspaceId} failed: method is not implemented in supervisor")
sendHeartBeatThroughSupervisorLogOnce = true
}
return
}
thisLogger().error("gitpod: sendHeartbeat ${connectParams.actualWorkspaceId} failed: ${resp.statusCode}, body: ${resp.body}")
return
}
thisLogger().debug("gitpod: sendHeartbeat succeed for ${connectParams.actualWorkspaceId}")
}

private fun resolveCredentials(
host: String,
port: Int,
Expand Down Expand Up @@ -589,7 +643,7 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
ownerToken: String
): CreateSSHKeyPairResponse? {
val value =
fetchWS("https://${ideUrl.host}/_supervisor/v1/ssh_keys/create", connectParams, ownerToken)
retryFetchWS("https://${ideUrl.host}/_supervisor/v1/ssh_keys/create", connectParams, ownerToken)
if (value.isNullOrBlank()) {
return null
}
Expand All @@ -604,7 +658,7 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
connectParams: ConnectParams
): List<SSHHostKey>? {
val hostKeysValue =
fetchWS("https://${ideUrl.host}/_ssh/host_keys", connectParams, null)
retryFetchWS("https://${ideUrl.host}/_ssh/host_keys", connectParams, null)
if (hostKeysValue.isNullOrBlank()) {
return null
}
Expand Down Expand Up @@ -671,27 +725,50 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
return acceptHostKey
}

data class HttpResponseData(val statusCode: Int, val body: String?) {
fun statusCode() = statusCode
fun body() = body
}

private suspend fun fetchWS(
endpointUrl: String,
connectParams: ConnectParams,
ownerToken: String?,
timeoutMillis: Long,
): HttpResponseData {
var httpRequestBuilder = HttpRequest.newBuilder()
.uri(URI.create(endpointUrl))
.GET()
.timeout(Duration.ofMillis(timeoutMillis))
if (!ownerToken.isNullOrBlank()) {
httpRequestBuilder = httpRequestBuilder.header("x-gitpod-owner-token", ownerToken)
}
val httpRequest = httpRequestBuilder.build()
val responseFuture =
httpClient.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofString())

try {
val response = responseFuture.await()
return HttpResponseData(response.statusCode(), response.body())
} catch (e: Exception) {
if (responseFuture.isCancelled) {
throw CancellationException()
}
throw e
}
}

private suspend fun retryFetchWS(
endpointUrl: String,
connectParams: ConnectParams,
ownerToken: String?
): String? {
val maxRequestTimeout = 30 * 1000L
val timeoutDelayGrowFactor = 1.5
var requestTimeout = 2 * 1000L
while (true) {
coroutineContext.job.ensureActive()
try {
var httpRequestBuilder = HttpRequest.newBuilder()
.uri(URI.create(endpointUrl))
.GET()
.timeout(Duration.ofMillis(requestTimeout))
if (!ownerToken.isNullOrBlank()) {
httpRequestBuilder = httpRequestBuilder.header("x-gitpod-owner-token", ownerToken)
}
val httpRequest = httpRequestBuilder.build()
val response =
httpClient.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofString()).await()
val response = fetchWS(endpointUrl, ownerToken, requestTimeout)
if (response.statusCode() == 200) {
return response.body()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ class GitpodSettingsConfigurable : BoundConfigurable("Gitpod") {
.comment("Helpful if you are behind a firewall/proxy that blocks SSH or " +
"have complicated SSH setup (bastions, proxy jumps, etc.)")
}
row {
checkBox("Persistent connection heartbeats")
.bindSelected(state::additionalHeartbeat)
.comment("Keep workspaces running as long as the IDE connection remains active.")
}

}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ class GitpodSettingsState : PersistentStateComponent<GitpodSettingsState> {
dispatcher.multicaster.didChange()
}

var additionalHeartbeat: Boolean = false
set(value) {
if (value == field) {
return
}
field = value
dispatcher.multicaster.didChange()
}

private interface Listener : EventListener {
fun didChange()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ package io.gitpod.jetbrains.gateway

import com.intellij.icons.AllIcons
import com.intellij.ide.BrowserUtil
import com.intellij.ide.plugins.PluginManagerCore
import com.intellij.openapi.CompositeDisposable
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.extensions.PluginId
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager
import com.intellij.remoteDev.util.onTerminationOrNow
Expand Down Expand Up @@ -106,10 +108,12 @@ class GitpodWorkspacesView(
}
}.visibleIf(loggedIn.not())

val pluginVersion = PluginManagerCore.getPlugin(PluginId.getId("io.gitpod.jetbrains.gateway"))?.version
val pluginVersionLabel = if (pluginVersion?.contains("-local") == true) " (${pluginVersion})" else ""
rowsRange {
row {
icon(GitpodIcons.Logo).gap(RightGap.SMALL)
label("Gitpod").applyToComponent {
label("Gitpod${pluginVersionLabel}").applyToComponent {
this.font = JBFont.h3().asBold()
}
label("").resizableColumn().align(AlignX.FILL)
Expand Down
11 changes: 11 additions & 0 deletions components/supervisor-api/control.proto
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ service ControlService {

// CreateDebugEnv creates a debug workspace envs
rpc CreateDebugEnv(CreateDebugEnvRequest) returns (CreateDebugEnvResponse) {}

// SendHeartBeat sends a heartbeat to server to keep the workspace alive
rpc SendHeartBeat(SendHeartBeatRequest) returns (SendHeartBeatResponse) {
option (google.api.http) = {
get: "/v1/send_heartbeat"
};
}
}

message ExposePortRequest {
Expand Down Expand Up @@ -73,3 +80,7 @@ message CreateDebugEnvRequest {
message CreateDebugEnvResponse {
repeated string envs = 1;
}

message SendHeartBeatRequest {}

message SendHeartBeatResponse {}
Loading

0 comments on commit b6c243b

Please sign in to comment.