Skip to content
Open
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
201 changes: 171 additions & 30 deletions src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2024 Red Hat, Inc.
* Copyright (c) 2024-2025 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
Expand All @@ -11,81 +11,178 @@
*/
package com.redhat.devtools.gateway

import com.redhat.devtools.gateway.openshift.DevWorkspaces
import com.redhat.devtools.gateway.openshift.Pods
import com.redhat.devtools.gateway.server.RemoteIDEServer
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.ui.Messages
import com.jetbrains.gateway.thinClientLink.LinkedClientManager
import com.jetbrains.gateway.thinClientLink.ThinClientHandle
import com.jetbrains.rd.util.lifetime.Lifetime
import com.redhat.devtools.gateway.openshift.DevWorkspaces
import com.redhat.devtools.gateway.openshift.Pods
import com.redhat.devtools.gateway.server.RemoteIDEServer
import com.redhat.devtools.gateway.server.RemoteIDEServerStatus
import io.kubernetes.client.openapi.ApiException
import okio.Closeable
import java.io.IOException
import java.net.URI
import java.util.concurrent.CancellationException
import java.util.concurrent.atomic.AtomicInteger

class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) {
@Throws(Exception::class)
@Throws(Exception::class, CancellationException::class)
@Suppress("UnstableApiUsage")
fun connect(
onConnected: () -> Unit,
onDisconnected: () -> Unit,
onDevWorkspaceStopped: () -> Unit,
onProgress: ((message: String) -> Unit)? = null,
isCancelled: (() -> Boolean)? = null
): ThinClientHandle {
if (devSpacesContext.isConnected)
throw IOException(String.format("Already connected to %s", devSpacesContext.devWorkspace.metadata.name))

devSpacesContext.isConnected = true
try {
return doConnection(onConnected, onDevWorkspaceStopped, onDisconnected)
return doConnection(onConnected, onDevWorkspaceStopped, onDisconnected, onProgress, isCancelled)
} catch (e: Exception) {
devSpacesContext.isConnected = false
throw e
}
}

@Throws(Exception::class)
@Throws(Exception::class, CancellationException::class)
@Suppress("UnstableApiUsage")
private fun doConnection(
onConnected: () -> Unit,
onDevWorkspaceStopped: () -> Unit,
onDisconnected: () -> Unit
onDisconnected: () -> Unit,
onProgress: ((message: String) -> Unit)? = null,
isCancelled: (() -> Boolean)? = null
): ThinClientHandle {
startAndWaitDevWorkspace()
startAndWaitDevWorkspace(onProgress)
if (isCancelled?.invoke() == true) {
throw CancellationException("User cancelled the operation")
}

onProgress?.invoke("Waiting for the Remote IDE server to get ready...")
val (remoteIdeServer, remoteIdeServerStatus) =
try {
val remoteIdeServer = RemoteIDEServer(devSpacesContext).apply {
waitRemoteIDEServerReady()
}
remoteIdeServer to remoteIdeServer.getStatus()
} catch (_: IOException) {
null to RemoteIDEServerStatus.empty()
}

if (isCancelled?.invoke() == true) {
throw CancellationException("User cancelled the operation")
}

if (remoteIdeServer == null || !remoteIdeServerStatus.isReady) {
thisLogger().debug("Remote IDE server is in an invalid state. Please restart the pod and try again. ")
val result = AtomicInteger(-1)
ApplicationManager.getApplication().invokeAndWait {
result.set(
Messages.showDialog(
"The Remote IDE Server is not responding properly.\n" +
"Would you like to try restarting the Pod or cancel the connection?",
"Remote IDE Server Issue",
arrayOf("Cancel Connection", "Restart Pod and try again"),
0, // default selected index
Messages.getWarningIcon()
)
)
}

when (result.get()) {
1 -> {
// User chose "Restart Pod"
thisLogger().info("User chose to restart the pod.")
stopAndWaitDevWorkspace(onProgress)
if (isCancelled?.invoke() == true) {
throw CancellationException("User cancelled the operation")
}
return doConnection(onConnected, onDevWorkspaceStopped, onDisconnected, onProgress, isCancelled)
}
}

val remoteIdeServer = RemoteIDEServer(devSpacesContext)
val remoteIdeServerStatus = remoteIdeServer.getStatus()
// User chose "Cancel Connection"
thisLogger().info("User cancelled the remote IDE connection.")
throw IllegalStateException("Remote IDE server is not responding properly. Try restarting the pod and reconnecting.")
}

val client = LinkedClientManager
.getInstance()
.startNewClient(
Lifetime.Eternal,
URI(remoteIdeServerStatus.joinLink),
URI(remoteIdeServerStatus.joinLink!!),
Copy link
Collaborator

Choose a reason for hiding this comment

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

I saw situations where the joinLink was null. I believe that it would be better to check the joinLink for being non-empty and throw instead of asserting it to be non-null (!!)

"",
onConnected,
false
)

val forwarder = Pods(devSpacesContext.client).forward(remoteIdeServer.pod, 5990, 5990)
Copy link
Collaborator

Choose a reason for hiding this comment

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

#forward can fail, too ex. if local port is already bound.


client.run {
lifetime.onTermination { forwarder.close() }
lifetime.onTermination {
if (remoteIdeServer.waitServerTerminated())
DevWorkspaces(devSpacesContext.client)
.stop(
devSpacesContext.devWorkspace.metadata.namespace,
devSpacesContext.devWorkspace.metadata.name
)
.also { onDevWorkspaceStopped() }
try {
client.run {
lifetime.onTermination {
cleanup(forwarder, remoteIdeServer, devSpacesContext, onDevWorkspaceStopped, onDisconnected)
}
}
lifetime.onTermination { devSpacesContext.isConnected = false }
lifetime.onTermination(onDisconnected)
} catch (e: Exception) {
cleanup(forwarder, remoteIdeServer, devSpacesContext, onDevWorkspaceStopped, onDisconnected)
throw e // rethrow so caller can handle the original problem
}

return client
}

@Throws(IOException::class, ApiException::class)
private fun startAndWaitDevWorkspace() {
if (!devSpacesContext.devWorkspace.spec.started) {
private fun cleanup(
forwarder: Closeable?,
remoteIdeServer: RemoteIDEServer?,
devSpacesContext: DevSpacesContext,
onDevWorkspaceStopped: () -> Unit,
onDisconnected: () -> Unit
) {
try {
Copy link
Collaborator

Choose a reason for hiding this comment

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

nitpicking: I'd create separate methods for all 3 closing operations (closeForwarder(), stopDevWorkspace(), invokeOnDisconnected) and call them here.

forwarder?.close()
thisLogger().info("Closed port forwarder")
} catch (e: Exception) {
thisLogger().debug("Failed to close port forwarder", e)
}

try {
if (remoteIdeServer?.isRemoteIdeServerState(false) == true) {
DevWorkspaces(devSpacesContext.client)
.stop(
devSpacesContext.devWorkspace.metadata.namespace,
devSpacesContext.devWorkspace.metadata.name
)
.also { onDevWorkspaceStopped() }
}
} catch (e: Exception) {
thisLogger().debug("Failed to stop DevWorkspace", e)
}

devSpacesContext.isConnected = false

try {
onDisconnected()
} catch (e: Exception) {
thisLogger().debug("onDisconnected handler failed", e)
}
}


@Throws(IOException::class, ApiException::class, CancellationException::class)
private fun startAndWaitDevWorkspace(onProgress: ((message: String) -> Unit)? = null,
isCancelled: (() -> Boolean)? = null) {
// We really need a refreshed DevWorkspace here
val devWorkspace = DevWorkspaces(devSpacesContext.client).get(
devSpacesContext.devWorkspace.metadata.namespace,
devSpacesContext.devWorkspace.metadata.name)

if (!devWorkspace.spec.started) {
DevWorkspaces(devSpacesContext.client)
.start(
devSpacesContext.devWorkspace.metadata.namespace,
Expand All @@ -94,11 +191,17 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) {
}

if (!DevWorkspaces(devSpacesContext.client)
.waitPhase(
.waitForPhase(
devSpacesContext.devWorkspace.metadata.namespace,
devSpacesContext.devWorkspace.metadata.name,
DevWorkspaces.RUNNING,
DevWorkspaces.RUNNING_TIMEOUT
onProgress = { phase, message ->
onProgress?.invoke(buildString {
append("Phase: $phase")
if (message.isNotBlank()) append(" – $message")
})
},
isCancelled = { isCancelled?.invoke() ?: false }
)
) throw IOException(
String.format(
Expand All @@ -108,4 +211,42 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) {
)
)
}

@Throws(IOException::class, ApiException::class, CancellationException::class)
private fun stopAndWaitDevWorkspace(onProgress: ((message: String) -> Unit)? = null,
isCancelled: (() -> Boolean)? = null) {
// We really need a refreshed DevWorkspace here
val devWorkspace = DevWorkspaces(devSpacesContext.client).get(
devSpacesContext.devWorkspace.metadata.namespace,
devSpacesContext.devWorkspace.metadata.name)

if (devWorkspace.spec.started) {
DevWorkspaces(devSpacesContext.client)
.stop(
devSpacesContext.devWorkspace.metadata.namespace,
devSpacesContext.devWorkspace.metadata.name
)
}

if (!DevWorkspaces(devSpacesContext.client)
.waitForPhase(
devSpacesContext.devWorkspace.metadata.namespace,
devSpacesContext.devWorkspace.metadata.name,
DevWorkspaces.STOPPED,
onProgress = { phase, message ->
onProgress?.invoke(buildString {
append("Phase: $phase")
if (message.isNotBlank()) append(" – $message")
})
},
isCancelled = { isCancelled?.invoke() ?: false }
)
) throw IOException(
String.format(
"DevWorkspace '%s' is not stopped after %d seconds",
devSpacesContext.devWorkspace.metadata.name,
DevWorkspaces.RUNNING_TIMEOUT
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.awt.BorderLayout
import java.awt.Dimension
import javax.swing.Action
import javax.swing.Box
import javax.swing.BoxLayout
import javax.swing.JComponent
import javax.swing.JLabel
import javax.swing.JPanel
import javax.swing.JProgressBar
import javax.swing.Timer
import java.util.concurrent.CancellationException
import javax.swing.*

private const val DW_NAMESPACE = "dwNamespace"
private const val DW_NAME = "dwName"
Expand Down Expand Up @@ -97,6 +91,12 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider {
} else {
throw err
}
} catch (e: CancellationException) {
indicator.setText("${e.message}")
Timer(2000) {
indicator.close(DialogWrapper.CANCEL_EXIT_CODE)
}.start()
null
} catch (e: Exception) {
indicator.setText("Unexpected error: ${e.message}")
Timer(2000) {
Expand Down Expand Up @@ -189,7 +189,13 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider {
ctx.devWorkspace = DevWorkspaces(ctx.client).get(dwNamespace, dwName)

indicator?.text2 = "Establishing remote IDE connection…"
val thinClient = DevSpacesConnection(ctx).connect({}, {}, {})
val thinClient = DevSpacesConnection(ctx).connect({}, {}, {},
onProgress = { message ->
if (!message.isEmpty()) {
indicator?.text2 = message
}
},
isCancelled = { indicator?.isShowing == false })

indicator?.text2 = "Connection established successfully."
return DevSpacesConnectionHandle(thinClient.lifetime, thinClient, { createComponent(dwName) }, dwName)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2024 Red Hat, Inc.
* Copyright (c) 2024-2025 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
Expand Down Expand Up @@ -77,14 +77,22 @@ data class DevWorkspaceSpec(
}

data class DevWorkspaceStatus(
val phase: String
val phase: String,
val message: String
) {
companion object {
fun from(map: Any) = object {
val phase = Utils.getValue(map, arrayOf("phase")) ?: ""

val conditions = Utils.getValue(map, arrayOf("conditions")) as? List<Map<String, Any>>

val notReadyCondition = conditions
?.firstOrNull { it["status"] == "False" }
val message = notReadyCondition?.get("message") as? String ?: ""

val data = DevWorkspaceStatus(
phase as String
phase as String,
message
)
}.data
}
Expand Down
Loading
Loading