diff --git a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt index a47b0a1..7af8962 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt @@ -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/ @@ -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!!), "", onConnected, false ) val forwarder = Pods(devSpacesContext.client).forward(remoteIdeServer.pod, 5990, 5990) - - 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 { + 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, @@ -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( @@ -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 + ) + ) + } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt index 444053b..328b811 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt @@ -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" @@ -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) { @@ -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) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspace.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspace.kt index d05b5ef..383b278 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspace.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspace.kt @@ -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/ @@ -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> + + 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 } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspaces.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspaces.kt index 82ce68c..a3fe38b 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspaces.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspaces.kt @@ -21,16 +21,21 @@ import io.kubernetes.client.openapi.apis.CustomObjectsApi import io.kubernetes.client.util.PatchUtils import io.kubernetes.client.util.Watch import java.io.IOException +import java.time.Duration +import java.time.Instant +import java.util.concurrent.CancellationException import java.util.concurrent.Executors import java.util.concurrent.TimeUnit + class DevWorkspaces(private val client: ApiClient) { companion object { - val FAILED: String = "Failed" - val RUNNING: String = "Running" - val STOPPED: String = "Stopped" - val STARTING: String = "Starting" - val RUNNING_TIMEOUT: Long = 300 + const val FAILED: String = "Failed" + const val RUNNING: String = "Running" + const val STOPPED: String = "Stopped" + const val STARTING: String = "Starting" + const val RUNNING_TIMEOUT: Long = 600 + const val INACTIVITY_TIMEOUT: Long = 150 } @Throws(ApiException::class) @@ -171,4 +176,61 @@ class DevWorkspaces(private val client: ApiClient) { object : TypeToken>() {}.type ) } + + @Throws(ApiException::class, IOException::class, CancellationException::class) + fun waitForPhase( + namespace: String, + name: String, + desiredPhase: String, + maxWaitTimeSeconds: Long = RUNNING_TIMEOUT, + maxInactivitySeconds: Long = INACTIVITY_TIMEOUT, + onProgress: ((phase: String, message: String) -> Unit)? = null, + isCancelled: (() -> Boolean)? = null + ): Boolean { + var reached = false + var lastPhase = "" + var lastMessage = "" + var lastChangeTime = Instant.now() + + val watcher = createWatcher(namespace, String.format("metadata.name=%s", name)) + val deadline = Instant.now().plusSeconds(maxWaitTimeSeconds) + + try { + for (event in watcher) { + if (isCancelled?.invoke() == true) { + throw CancellationException("User cancelled the operation") + } + + val devWorkspace = DevWorkspace.from(event.`object`) + val currentPhase = devWorkspace.status.phase ?: "Unknown" + val currentMessage = devWorkspace.status.message ?: "" + + if (currentPhase != lastPhase || currentMessage != lastMessage) { + onProgress?.invoke(currentPhase, currentMessage) + lastPhase = currentPhase + lastMessage = currentMessage + lastChangeTime = Instant.now() + } + + if (currentPhase == desiredPhase) { + reached = true + break + } + + if (Duration.between(lastChangeTime, Instant.now()).seconds > maxInactivitySeconds) { + onProgress?.invoke(currentPhase, "No progress in $maxInactivitySeconds seconds.") + break + } + + if (Instant.now().isAfter(deadline)) { + onProgress?.invoke(currentPhase, "Timed out after $maxWaitTimeSeconds seconds.") + break + } + } + } finally { + watcher.close() + } + + return reached + } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt index 9a49f2d..ec376d4 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt @@ -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/ @@ -19,7 +19,6 @@ import com.redhat.devtools.gateway.DevSpacesContext import com.redhat.devtools.gateway.openshift.Pods import io.kubernetes.client.openapi.models.V1Container import io.kubernetes.client.openapi.models.V1Pod -import org.bouncycastle.util.Arrays import java.io.IOException /** @@ -68,8 +67,8 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { } @Throws(IOException::class) - fun waitServerReady() { - doWaitServerState(true) + fun waitRemoteIDEServerReady() { + doWaitForRemoteIdeServerState(true) .also { if (!it) throw IOException( String.format( @@ -80,22 +79,32 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { } } - @Throws(IOException::class) - fun waitServerTerminated(): Boolean { - return doWaitServerState(false) - } - - @Throws(IOException::class) - fun doWaitServerState(isReadyState: Boolean): Boolean { + fun isRemoteIdeServerState(isReadyState: Boolean): Boolean { return try { - val status = getStatus() - isReadyState == !Arrays.isNullOrEmpty(status.projects) + (getStatus().isReady == isReadyState) } catch (e: Exception) { thisLogger().debug("Failed to check remote IDE server state.", e) false } } + fun doWaitForRemoteIdeServerState( + isReadyState: Boolean, + timeout: Long = readyTimeout, + ): Boolean { + var delayMillis = 1000L + val maxDelayMillis = 8000L + val deadline = System.currentTimeMillis() + timeout * 1000 + while (System.currentTimeMillis() < deadline) { + if (isRemoteIdeServerState(isReadyState)) { + return true + } + Thread.sleep(delayMillis) + delayMillis = (delayMillis * 2).coerceAtMost(maxDelayMillis) + } + return false // Timeout + } + @Throws(IOException::class) private fun findPod(): V1Pod { val selector = diff --git a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerStatus.kt b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerStatus.kt index ef9f215..9f1177f 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerStatus.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerStatus.kt @@ -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/ @@ -13,18 +13,19 @@ package com.redhat.devtools.gateway.server data class RemoteIDEServerStatus( - val joinLink: String, - val httpLink: String, - val gatewayLink: String, + val joinLink: String?, + val httpLink: String?, + val gatewayLink: String?, val appVersion: String, val runtimeVersion: String, - val projects: Array + val projects: Array? ) { companion object { fun empty(): RemoteIDEServerStatus { return RemoteIDEServerStatus("", "", "", "", "", emptyArray()) } } + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -36,19 +37,26 @@ data class RemoteIDEServerStatus( if (gatewayLink != other.gatewayLink) return false if (appVersion != other.appVersion) return false if (runtimeVersion != other.runtimeVersion) return false - if (!projects.contentEquals(other.projects)) return false + if (projects != null) { + if (other.projects == null || !projects.contentEquals(other.projects)) return false + } else if (other.projects != null) { + return false + } return true } override fun hashCode(): Int { - var result = joinLink.hashCode() - result = 31 * result + httpLink.hashCode() - result = 31 * result + gatewayLink.hashCode() + var result = joinLink?.hashCode() ?: 0 + result = 31 * result + (httpLink?.hashCode() ?: 0) + result = 31 * result + (gatewayLink?.hashCode() ?: 0) result = 31 * result + appVersion.hashCode() result = 31 * result + runtimeVersion.hashCode() - result = 31 * result + projects.contentHashCode() + result = 31 * result + (projects?.contentHashCode() ?: 0) return result } + + val isReady: Boolean + get() = !joinLink.isNullOrBlank() && !projects.isNullOrEmpty() }