diff --git a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt index b8510955..451a0d88 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt @@ -28,6 +28,7 @@ import com.redhat.devtools.gateway.server.RemoteIDEServer import com.redhat.devtools.gateway.server.RemoteIDEServerStatus import com.redhat.devtools.gateway.util.ProgressCountdown import com.redhat.devtools.gateway.util.isCancellationException +import com.redhat.devtools.gateway.util.isIdeServerContainerNotFound import com.redhat.devtools.gateway.view.ui.Dialogs import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.openapi.models.V1Pod @@ -304,6 +305,8 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { remoteIdeServer.apply { waitServerReady(checkCancelled) }.getStatus(checkCancelled) }.getOrElse { e -> if (e.isCancellationException()) throw e + // Terminal: no idea-server container — do not offer "restart pod" (CRW-11897). + if (e.isIdeServerContainerNotFound()) throw e RemoteIDEServerStatus.empty() } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaces.kt b/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaces.kt index ed881bbc..e2aca97a 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaces.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaces.kt @@ -26,9 +26,22 @@ import kotlinx.coroutines.withTimeoutOrNull import java.io.IOException import java.util.concurrent.CancellationException +data class DevWorkspaceListItem( + val workspace: DevWorkspace, + val editorLabel: String +) + data class DevWorkspaceListResult( - val items: List, - val resourceVersion: String? + val items: List, + val resourceVersion: String?, + val templateMap: Map> = emptyMap(), + /** True when listing templates was ignored (401/403/404); empty map alone is not unavailable. */ + val templatesUnavailable: Boolean = false +) + +data class TemplateMapLoad( + val map: Map>, + val unavailable: Boolean // true when 401/403/404 ) val DevWorkspace.cheEditor: String @@ -61,38 +74,62 @@ class DevWorkspaces(private val client: ApiClient) { "devworkspaces" ).execute() - val devWorkspaceTemplateMap = getTemplateMap(namespace) + val templateMapLoad = loadTemplateMap(namespace) val dwItems = Utils.getValue(response, arrayOf("items")) as List<*> val dwList = dwItems .map { dwItem -> DevWorkspace.from(dwItem) } - .filter { isIdeaEditorBased(it, devWorkspaceTemplateMap) } + .map { dw -> DevWorkspaceListItem(dw, resolveEditorLabel(dw, templateMapLoad.map)) } val lastResourceVersion = (Utils.getValue(response, arrayOf("metadata", "resourceVersion")) as String?) - return DevWorkspaceListResult(dwList, lastResourceVersion) + return DevWorkspaceListResult( + dwList, + lastResourceVersion, + templateMapLoad.map, + templatesUnavailable = templateMapLoad.unavailable + ) } catch (e: ApiException) { thisLogger().info(e.message) - return when (e.code) { - 403, 404 -> { - // There might be some namespaces (OpenShift projects) in which the user cannot list resource "devworkspaces" - // e.g. "openshift-virtualization-os-images" on Red Hat Dev Sandbox, or the given cluster doesn't have - // the RedHat DevSpaces operator installed on it, etc. - // - // It doesn't make sense to show an error to the user in such cases, - // so let's skip it silently. - DevWorkspaceListResult(emptyList(), null) - } - else -> { - thisLogger().error("Kubernetes API error ${e.code}", e) - throw e - } + if (e.shouldBeIgnored()) { + // There might be some namespaces (OpenShift projects) in which the user cannot list resource "devworkspaces" + // e.g. "openshift-virtualization-os-images" on Red Hat Dev Sandbox, or the given cluster doesn't have + // the RedHat DevSpaces operator installed on it, etc. + // + // It doesn't make sense to show an error to the user in such cases, + // so let's skip it silently. + return DevWorkspaceListResult(emptyList(), null) } + thisLogger().error("Kubernetes API error ${e.code}", e) + throw e } } @Throws(ApiException::class) fun list(namespace: String): List { - return listWithResult(namespace).items + return listWithResult(namespace).items.map { it.workspace } + } + + fun resolveEditorLabel( + devWorkspace: DevWorkspace, + templateMap: Map> + ): String { + val cheEditor = Utils.getValue( + devWorkspace.annotations, + arrayOf("che.eclipse.org/che-editor") + ) as? String + if (!cheEditor.isNullOrBlank()) { + // If any path segment matches the JetBrains editor id regex -> "JetBrains" + if (devWorkspace.cheEditor.split("/").any { CHE_EDITOR_ID_REGEX.matches(it) }) { + return "JetBrains" + } + // Otherwise use a short segment from the annotation + return cheEditor.split("/").firstOrNull { it.isNotBlank() } ?: "Unknown" + } + // No annotation: check templates for an idea-server volume + if (isIdeaEditorBased(devWorkspace, templateMap)) { + return "JetBrains" + } + return "Unknown" } fun isIdeaEditorBased(devWorkspace: DevWorkspace, devWorkspaceTemplateMap: Map>): Boolean { @@ -116,16 +153,6 @@ class DevWorkspaces(private val client: ApiClient) { } } - // Creates a filter for the Idea-based DevWorkspaces - fun createIdeaEditorFilter( - namespace: String - ): (DevWorkspace) -> Boolean { - val templateMap = getTemplateMap(namespace) - return { dw: DevWorkspace -> - isIdeaEditorBased(dw, templateMap) - } - } - fun get(namespace: String, name: String): DevWorkspace { val dwObj = customApi.getNamespacedCustomObject( "workspace.devfile.io", @@ -137,8 +164,8 @@ class DevWorkspaces(private val client: ApiClient) { return DevWorkspace.from(dwObj) } - // Returns a map of DW Owner UID tp list of DW Templates - private fun getTemplateMap(namespace: String): Map> { + // Returns a map of DW Owner UID to list of DW Templates, plus availability flag. + fun loadTemplateMap(namespace: String): TemplateMapLoad { try { val dwTemplateList = customApi .listNamespacedCustomObject( @@ -150,7 +177,7 @@ class DevWorkspaces(private val client: ApiClient) { .execute() val items = Utils.getValue(dwTemplateList, arrayOf("items")) as? List<*> ?: emptyList() - return items + val map = items .map { DevWorkspaceTemplate.from(it) } .flatMap { templ -> templ.ownerRefencesUids.map { uid -> uid to templ } @@ -159,9 +186,10 @@ class DevWorkspaces(private val client: ApiClient) { keySelector = { it.first }, // UID valueTransform = { it.second } // DevWorkspaceTemplate ) + return TemplateMapLoad(map, unavailable = false) } catch (e: ApiException) { if (e.shouldBeIgnored()) { - return emptyMap() + return TemplateMapLoad(emptyMap(), unavailable = true) } thisLogger().info(e.message) throw e diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt index e8dddbde..e1f54a4e 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt @@ -124,7 +124,7 @@ fun ApiException.codeToReasonPhrase(): String = statusCodeReasonPhrase(code) fun Int.reasonPhrase(): String = statusCodeReasonPhrase(this) fun ApiException.shouldBeIgnored(): Boolean = - code == 403 || code == 404 + code == 401 || code == 403 || code == 404 fun ApiException.isRetryable(): Boolean = code in setOf(429, 500, 502, 503, 504) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePods.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePods.kt index e6536e67..c1e83a8a 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePods.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePods.kt @@ -13,9 +13,7 @@ package com.redhat.devtools.gateway.openshift import com.intellij.openapi.diagnostic.logger import com.redhat.devtools.gateway.util.isCancellationException -import com.redhat.devtools.gateway.openshift.apiclient.ApiClientUtils import io.kubernetes.client.PortForward -import io.kubernetes.client.custom.IOTrio import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.openapi.ApiException import io.kubernetes.client.openapi.apis.CoreV1Api @@ -27,8 +25,6 @@ import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.net.* -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException class DevWorkspacePods(private val client: ApiClient) { @@ -60,141 +56,19 @@ class DevWorkspacePods(private val client: ApiClient) { container: String, timeout: Long = 60, checkCancelled: (() -> Unit)? = null - ): String = suspendCancellableCoroutine { cont -> - val metadata = pod.metadata - ?: throw IllegalArgumentException("Pod metadata is missing") - val namespace = metadata.namespace - ?: throw IllegalArgumentException("Pod namespace is missing") - val podName = metadata.name - ?: throw IllegalArgumentException("Pod name is missing") - - val closed = CompletableDeferred() - val stdout = StringBuilder() - val stderr = StringBuilder() - - val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - val execClientApi = createIsolatedExecClient(client) - var stdoutJob: Job? = null - var stderrJob: Job? = null - lateinit var stdoutStream: InputStream - lateinit var stderrStream: InputStream - - try { - val execHandle = ContainerAwareExec(execClientApi).containerAwareExec( - namespace = namespace, - pod = podName, - container = container, - command = command, - onOpen = { io -> - launchCheckCancelled(checkCancelled, scope, io) - - stdoutJob = scope.launch { readStream(io.stdout, stdout, checkCancelled) } - stderrJob = scope.launch { readStream(io.stderr, stderr, checkCancelled) } - launchJoinStdOutStdErr(scope, stdoutJob, stderrJob, checkCancelled, closed, cont, stdout, execClientApi) - }, - onClosed = { _, _ -> - closed.complete(Unit) - }, - onError = { err, _ -> - closed.complete(Unit) - shutdownExecClient(execClientApi) - cont.resumeWithException(err) - }, - timeoutMs = timeout * 1000, - tty = false - ) - - cont.invokeOnCancellation { cause -> - try { stdoutStream.close() } catch (_: Throwable) {} - try { stderrStream.close() } catch (_: Throwable) {} - try { - execHandle.job.cancel(CancellationException("Pods.exec cancellation")) - execHandle.future.cancel(true) - } catch (_: Throwable) {} - scope.cancel() - shutdownExecClient(execClientApi) - } - } catch (e: Exception) { - shutdownExecClient(execClientApi) - if (cont.isActive) cont.resumeWithException(e) - } + ): String { + val metadata = pod.metadata ?: throw IOException("Pod metadata is missing") + return PodExecSession( + client = client, + namespace = metadata.namespace, + podName = metadata.name, + container = container, + command = command, + timeout = timeout, + checkCancelled = checkCancelled + ).execute() } - private fun launchJoinStdOutStdErr( - scope: CoroutineScope, - stdoutJob: Job, - stderrJob: Job, - checkCancelled: (() -> Unit)?, - closed: CompletableDeferred, - cont: CancellableContinuation, - stdout: StringBuilder, - execClientApi: ApiClient - ) { - scope.launch { - try { - listOfNotNull(stdoutJob, stderrJob).joinAll() - checkCancelled?.invoke() - closed.await() - - checkCancelled?.invoke() - if (cont.isActive) cont.resume(stdout.toString()) - } catch (e: Throwable) { - if (e.isCancellationException()) cont.cancel(e) - else if (cont.isActive) cont.resumeWithException(e) - } finally { - scope.cancel() - shutdownExecClient(execClientApi) - } - } - } - - private fun launchCheckCancelled( - checkCancelled: (() -> Unit)?, - scope: CoroutineScope, - io: IOTrio - ) { - if (checkCancelled == null) { - return - } - scope.launch { - try { - while (isActive) { - checkCancelled.invoke() - delay(200) - } - } catch (_: Throwable) { - runCatching { io.stdout.close() } - runCatching { io.stderr.close() } - } - } - } - - private fun shutdownExecClient(client: ApiClient) { - runCatching { client.httpClient.dispatcher.executorService.shutdownNow() } - runCatching { client.httpClient.connectionPool.evictAll() } - } - - private fun readStream( - input: InputStream, - output: StringBuilder, - checkCancelled: (() -> Unit)? - ) { - try { - while (true) { - checkCancelled?.invoke() - val b = input.read() - if (b == -1) break - output.append(b.toChar()) - } - } catch (_: IOException) { - // Stream was closed (possibly due to cancellation) - } - } - - private fun createIsolatedExecClient(base: ApiClient): ApiClient = - ApiClientUtils.cloneForExec(base) - @Throws(IOException::class) fun forward(pod: V1Pod, localPort: Int, remotePort: Int): Closeable { val serverSocket = ServerSocket(localPort, 50, InetAddress.getLoopbackAddress()) @@ -204,7 +78,7 @@ class DevWorkspacePods(private val client: ApiClient) { ) scope.acceptConnections(serverSocket, pod, localPort, remotePort) return Closeable { - runCatching { serverSocket.close() } + closeQuietly(serverSocket) scope.cancel() } } @@ -282,7 +156,7 @@ class DevWorkspacePods(private val client: ApiClient) { "Could not port forward to pod ${pod.metadata?.name} using port $localPort -> $remotePort", e) } finally { - runCatching { clientSocket.close() } + closeQuietly(clientSocket) } } @@ -314,8 +188,13 @@ class DevWorkspacePods(private val client: ApiClient) { } private fun closeStreams(port: Int, forwardResult: PortForward.PortForwardResult?) { - runCatching { forwardResult?.getInputStream(port)?.close() } - runCatching { forwardResult?.getOutboundStream(port)?.close() } + // getInputStream/getOutboundStream can throw; isolate so one failure does not skip the other close + runCatching { forwardResult?.getInputStream(port) } + .onSuccess { closeQuietly(it) } + .onFailure { logger.debug("Could not get input stream for port $port while closing port-forward", it) } + runCatching { forwardResult?.getOutboundStream(port) } + .onSuccess { closeQuietly(it) } + .onFailure { logger.debug("Could not get outbound stream for port $port while closing port-forward", it) } } private fun InputStream.copyToAndFlush(destination: OutputStream) { diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/PodExecSession.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/PodExecSession.kt new file mode 100644 index 00000000..3d2173cb --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/PodExecSession.kt @@ -0,0 +1,208 @@ +/* + * 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/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.openshift + +import com.redhat.devtools.gateway.openshift.apiclient.ApiClientUtils +import com.redhat.devtools.gateway.util.isCancellationException +import io.kubernetes.client.custom.IOTrio +import io.kubernetes.client.openapi.ApiClient +import kotlinx.coroutines.* +import java.io.IOException +import java.io.InputStream +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +internal class PodExecSession( + private val client: ApiClient, + namespace: String?, + podName: String?, + private val container: String, + private val command: Array, + private val timeout: Long, + private val checkCancelled: (() -> Unit)? +) { + private val namespace: String = namespace + ?: throw IOException("Pod namespace is missing") + private val podName: String = podName + ?: throw IOException("Pod name is missing") + + suspend fun execute(): String = suspendCancellableCoroutine { cont -> + val ctx = ExecContext() + val joinerJob = launchJoiner(ctx, cont) + + var execHandle: ContainerAwareExec.ExecHandle? = null + cont.invokeOnCancellation { + ctx.streamsReady.complete(Unit) + closeQuietly(ctx.io?.stdin) + closeQuietly(ctx.io?.stdout) + closeQuietly(ctx.io?.stderr) + runCatching { joinerJob.cancel("Pods.exec cancellation") } + execHandle?.let { handle -> + runCatching { + handle.job.cancel(CancellationException("Pods.exec cancellation")) + handle.future.cancel(true) + } + } + ctx.scope.cancel() + } + + try { + execHandle = runExec(ctx) + } catch (e: Exception) { + ctx.streamsReady.complete(Unit) + ctx.exitCode.completeExceptionally(e) + } + } + + private fun launchJoiner(ctx: ExecContext, cont: CancellableContinuation): Job = + ctx.scope.launch { + try { + ctx.streamsReady.await() + listOfNotNull(ctx.stdoutJobRef.get(), ctx.stderrJobRef.get()).joinAll() + checkCancelled?.invoke() + val code = ctx.exitCode.await() + + checkCancelled?.invoke() + val stderrMsg = ctx.stderr.toString().takeIf { it.isNotBlank() } + ?.let { "; stderr: ${it.take(2000)}" }.orEmpty() + when { + code == Int.MAX_VALUE -> { + if (cont.isActive) cont.resumeWithException(IOException("Pod exec timed out after ${timeout}s$stderrMsg")) + } + code != 0 -> { + if (cont.isActive) cont.resumeWithException(IOException("Pod exec failed with exit code $code$stderrMsg")) + } + else -> { + val readError = ctx.streamReadError.get() + if (readError != null) { + if (cont.isActive) { + cont.resumeWithException( + IOException( + "Pod exec stream closed before output was fully read$stderrMsg", + readError + ) + ) + } + } else if (cont.isActive) { + cont.resume(ctx.stdout.toString()) + } + } + } + } catch (e: Throwable) { + if (e.isCancellationException()) cont.cancel(e) + else if (cont.isActive) cont.resumeWithException(e) + } finally { + ctx.scope.cancel() + shutdownExecClient(ctx.execClient) + } + } + + private fun runExec(ctx: ExecContext): ContainerAwareExec.ExecHandle = + ContainerAwareExec(ctx.execClient).containerAwareExec( + namespace = namespace, + pod = podName, + container = container, + command = command, + onOpen = { i -> + ctx.io = i + launchCheckCancelled(checkCancelled, ctx.scope, i) + ctx.stdoutJobRef.set( + ctx.scope.launch { readStream(i.stdout, ctx.stdout, checkCancelled, ctx.streamReadError) } + ) + ctx.stderrJobRef.set( + ctx.scope.launch { readStream(i.stderr, ctx.stderr, checkCancelled, ctx.streamReadError) } + ) + ctx.streamsReady.complete(Unit) + }, + onClosed = { code, _ -> + ctx.streamsReady.complete(Unit) + ctx.exitCode.complete(code) + }, + onError = { err, _ -> + ctx.exitCode.completeExceptionally(err) + closeQuietly(ctx.io?.stdout) + closeQuietly(ctx.io?.stderr) + ctx.streamsReady.complete(Unit) + }, + timeoutMs = timeout * 1000, + tty = false + ) + + private fun launchCheckCancelled( + checkCancelled: (() -> Unit)?, + scope: CoroutineScope, + io: IOTrio + ) { + if (checkCancelled == null) return + scope.launch { + try { + while (isActive) { + checkCancelled.invoke() + @Suppress("ConvertLongToDuration") + delay(200) + } + } catch (_: Throwable) { + closeQuietly(io.stdout) + closeQuietly(io.stderr) + } + } + } + + private fun shutdownExecClient(client: ApiClient) { + runCatching { + val executor = client.httpClient.dispatcher.executorService + executor.shutdownNow() + executor.awaitTermination(500, TimeUnit.MILLISECONDS) + } + runCatching { client.httpClient.connectionPool.evictAll() } + } + + private fun readStream( + input: InputStream, + output: StringBuilder, + checkCancelled: (() -> Unit)?, + streamReadError: AtomicReference + ) { + try { + val reader = input.reader(Charsets.UTF_8) + val buffer = CharArray(4096) + while (true) { + checkCancelled?.invoke() + val read = reader.read(buffer) + if (read == -1) break + output.appendRange(buffer, 0, read) + } + } catch (e: IOException) { + // Closed during cancel/timeout/onError — recorded so exit 0 cannot return a partial buffer + streamReadError.compareAndSet(null, e) + } + } + + private fun createIsolatedExecClient(base: ApiClient): ApiClient = + ApiClientUtils.cloneForExec(base) + + private inner class ExecContext( + val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + val exitCode: CompletableDeferred = CompletableDeferred(), + val streamsReady: CompletableDeferred = CompletableDeferred(), + val stdout: StringBuilder = StringBuilder(), + val stderr: StringBuilder = StringBuilder(), + val stdoutJobRef: AtomicReference = AtomicReference(null), + val stderrJobRef: AtomicReference = AtomicReference(null), + val streamReadError: AtomicReference = AtomicReference(null), + val execClient: ApiClient = createIsolatedExecClient(client), + var io: IOTrio? = null + ) + +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Utils.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Utils.kt index 88e06141..ac7c07e6 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Utils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Utils.kt @@ -11,6 +11,8 @@ */ package com.redhat.devtools.gateway.openshift +import java.io.Closeable + object Utils { @JvmStatic fun getValue(obj: Any?, path: Array): Any? { @@ -77,3 +79,5 @@ fun mapOfNotNull(vararg pairs: Pair): Map { }.toMap() } +fun closeQuietly(closeable: Closeable?) = runCatching { closeable?.close() } + 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 abf1a06d..90228f8c 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt @@ -22,6 +22,13 @@ import kotlinx.coroutines.* import java.io.IOException import java.util.concurrent.CancellationException +/** + * Thrown when the workspace pod no longer contains an idea-server container. + * This is a terminal condition — the workspace is unusable — so it must fail fast + * instead of being retried until the ready timeout elapses (CRW-11897). + */ +class IdeServerContainerNotFoundException(message: String) : IOException(message) + /** * Represent an IDE server running in a CDE. */ @@ -99,6 +106,42 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { } } + /** + * Rethrows [IdeServerContainerNotFoundException] — a terminal condition where the workspace + * is unusable — so it fails fast instead of being retried until the ready timeout elapses. + */ + private fun rethrowIfTerminal(e: Exception) { + if (e is IdeServerContainerNotFoundException) throw e + } + + /** + * Re-resolves the workspace pod and idea-server container. + * + * @return `true` when refreshed successfully, `false` on transient failures (retried). + * Terminal conditions (cancellation, missing idea-server container) are rethrown. + */ + @Throws(CancellationException::class) + private fun refreshPod(refreshFailures: IntArray): Boolean { + return try { + pod = findPod() + container = findContainer() + refreshFailures[0] = 0 + true + } catch (e: Exception) { + if (e.isCancellationException()) throw e + rethrowIfTerminal(e) + refreshFailures[0]++ + thisLogger().debug("Failed to refresh workspace pod during IDE state check", e) + if (refreshFailures[0] == REFRESH_FAILURE_WARNING_THRESHOLD) { + thisLogger().warn( + "Pod/container refresh has failed ${refreshFailures[0]} consecutive times; " + + "stale pod references may cause incorrect status checks" + ) + } + false + } + } + @Throws(CancellationException::class) private suspend fun isServerState( isReadyState: Boolean, @@ -107,26 +150,14 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { refreshFailures: IntArray = intArrayOf(0), ): Boolean { return try { - if (refreshPodBeforeCheck) { - runCatching { - pod = findPod() - container = findContainer() - }.onFailure { e -> - if (e.isCancellationException()) throw e - refreshFailures[0]++ - thisLogger().debug("Failed to refresh workspace pod during IDE state check", e) - if (refreshFailures[0] == REFRESH_FAILURE_WARNING_THRESHOLD) { - thisLogger().warn( - "Pod/container refresh has failed ${refreshFailures[0]} consecutive times; " + - "stale pod references may cause incorrect status checks" - ) - } - return false - }.onSuccess { refreshFailures[0] = 0 } + // Re-resolve pod while waiting for ready so a recycled pod is not missed. + if (refreshPodBeforeCheck && !refreshPod(refreshFailures)) { + return false } getStatus(checkCancelled).isReady == isReadyState } catch (e: Exception) { if (e.isCancellationException()) throw e + rethrowIfTerminal(e) thisLogger().debug("Failed to check workspace IDE state.", e) false } @@ -192,7 +223,7 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { return pod.spec!!.containers.find { container -> container.ports?.any { port -> port.name == "idea-server" } != null } - ?: throw IOException( + ?: throw IdeServerContainerNotFoundException( "Workspace IDE container not found in the Pod: ${pod.metadata?.name}" ) } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt index dfa37d89..b81d7bf4 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt @@ -12,6 +12,7 @@ package com.redhat.devtools.gateway.util import com.redhat.devtools.gateway.auth.session.SsoLoginException +import com.redhat.devtools.gateway.server.IdeServerContainerNotFoundException import kotlinx.coroutines.TimeoutCancellationException import java.util.concurrent.CancellationException import java.util.concurrent.TimeoutException @@ -25,6 +26,9 @@ fun Throwable.isTimeoutException(): Boolean = (this is TimeoutCancellationExcept fun Throwable.isCancellationException(): Boolean = (this is CancellationException && !isTimeoutException() ) +fun Throwable.isIdeServerContainerNotFound(): Boolean = + generateSequence(this) { it.cause }.any { it is IdeServerContainerNotFoundException } + fun Throwable.isLoginUserCancelled(): Boolean = generateSequence(this) { it.cause }.any { it is SsoLoginException.Cancelled } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWorkspacesStepView.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWorkspacesStepView.kt index a1966ee3..535410bb 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWorkspacesStepView.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWorkspacesStepView.kt @@ -31,7 +31,9 @@ import com.redhat.devtools.gateway.DevSpacesConnection import com.redhat.devtools.gateway.DevSpacesContext import com.redhat.devtools.gateway.DevSpacesIcons import com.redhat.devtools.gateway.devworkspace.DevWorkspace +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceListItem import com.redhat.devtools.gateway.devworkspace.DevWorkspaceListener +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceTemplate import com.redhat.devtools.gateway.devworkspace.DevWorkspaceWatchManager import com.redhat.devtools.gateway.devworkspace.DevWorkspaces import com.redhat.devtools.gateway.openshift.Projects @@ -39,15 +41,22 @@ import com.redhat.devtools.gateway.openshift.Utils import com.redhat.devtools.gateway.server.RemoteIDEServer import com.redhat.devtools.gateway.server.RemoteIDEServerStatus import com.redhat.devtools.gateway.util.isCancellationException +import com.redhat.devtools.gateway.util.isIdeServerContainerNotFound import com.redhat.devtools.gateway.util.messageWithoutPrefix import com.redhat.devtools.gateway.view.ui.Dialogs import com.redhat.devtools.gateway.view.ui.onDoubleClick import io.kubernetes.client.openapi.ApiClient +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.awt.Dimension import java.awt.FontMetrics import java.util.concurrent.CancellationException +import java.util.concurrent.ConcurrentHashMap import javax.swing.DefaultListModel import javax.swing.JButton import javax.swing.JList @@ -55,6 +64,9 @@ import javax.swing.ListModel import javax.swing.event.ListSelectionEvent import javax.swing.event.ListSelectionListener +private const val NO_JETBRAINS_IDE_CONTAINER_MESSAGE = + "The workspace does not have a JetBrains IDE (idea-server) container, so it cannot be connected to." + val DevWorkspace.displayName: String get() { val label = Utils.getValue(this.labels, arrayOf("kubernetes.io/metadata.name")) as String? @@ -69,7 +81,7 @@ class DevSpacesWorkspacesStepView( override val previousActionText = DevSpacesBundle.message("connector.wizard_step.remote_server_connection.button.previous") - private var listDWDataModel = DefaultListModel() + private var listDWDataModel = DefaultListModel() private var listDevWorkspaces = JBList(listDWDataModel) private lateinit var startDevWorkspaceButton: JButton @@ -127,6 +139,7 @@ class DevSpacesWorkspacesStepView( initListListeners(this) + watchManager?.dispose() watchManager = WorkspacesWatch(devSpacesContext.client, listDWDataModel) refreshAndWatchAllDevWorkspaces() enableButtons() @@ -150,6 +163,12 @@ class DevSpacesWorkspacesStepView( return false // canceled, stay on this step } thisLogger().error("Could not check workspace IDE status", e) + if (e.isIdeServerContainerNotFound()) { + // Terminal condition — no idea-server container; do not offer restart pod (CRW-11897). + // Walk the cause chain: ProgressManager/coroutines may wrap the original exception. + Dialogs.error(NO_JETBRAINS_IDE_CONTAINER_MESSAGE, "Cannot Connect to Workspace IDE") + return false + } if (Dialogs.ideNotResponding()) { stopDevWorkspace() connect() @@ -201,11 +220,17 @@ class DevSpacesWorkspacesStepView( private fun refreshAllDevWorkspaces(): Map { val lastResourceVersions = mutableMapOf() + val templateMaps = mutableMapOf>>() + val namespacesUnavailable = mutableSetOf() val devWorkspaces = Projects(devSpacesContext.client).list() .map { Utils.getValue(it, arrayOf("metadata", "name")) as String } .flatMap { namespace -> val dwListResult = DevWorkspaces(devSpacesContext.client).listWithResult(namespace) lastResourceVersions[namespace] = dwListResult.resourceVersion + templateMaps[namespace] = dwListResult.templateMap + if (dwListResult.templatesUnavailable) { + namespacesUnavailable.add(namespace) + } dwListResult.items } @@ -218,6 +243,8 @@ class DevSpacesWorkspacesStepView( listDevWorkspaces.selectedIndex = getValidSelectedIndex(selectedIndex) } + watchManager?.seedTemplateCache(templateMaps, namespacesUnavailable) + return lastResourceVersions } @@ -232,12 +259,26 @@ class DevSpacesWorkspacesStepView( private fun refreshDevWorkspace(namespace: String, name: String) { val refreshedDevWorkspace = DevWorkspaces(devSpacesContext.client).get(namespace, name) + val idx = indexOfFirst { it.workspace.namespace == namespace && it.workspace.name == name } + if (idx != -1) { + // Keep the previously resolved label: the freshly fetched DevWorkspace has no template + // context here, so a template-based JetBrains label must not flip to Unknown (CRW-11897). + listDWDataModel[idx] = DevWorkspaceListItem( + refreshedDevWorkspace, + listDWDataModel[idx].editorLabel + ) + } else { + thisLogger().debug( + "refreshDevWorkspace: $namespace/$name not in list model; skipping UI update" + ) + } + } - listDWDataModel - .indexOf(refreshedDevWorkspace) - .also { - if (it != -1) listDWDataModel[it] = refreshedDevWorkspace - } + private fun indexOfFirst(predicate: (DevWorkspaceListItem) -> Boolean): Int { + for (i in 0 until listDWDataModel.size) { + if (predicate(listDWDataModel[i])) return i + } + return -1 } private fun startDevWorkspace() { @@ -381,7 +422,14 @@ class DevSpacesWorkspacesStepView( ) enableButtons() thisLogger().error("Workspace IDE connection failed.", e) - Dialogs.error(e.messageWithoutPrefix() ?: "Could not connect to workspace IDE", "Connection Error") + if (e.isIdeServerContainerNotFound()) { + Dialogs.error(NO_JETBRAINS_IDE_CONTAINER_MESSAGE, "Cannot Connect to Workspace IDE") + } else { + Dialogs.error( + e.messageWithoutPrefix() ?: "Could not connect to workspace IDE", + "Connection Error" + ) + } } }, DevSpacesBundle.message("connector.loader.devspaces.connecting.text"), @@ -421,7 +469,7 @@ class DevSpacesWorkspacesStepView( val selectedIndex = listDevWorkspaces.minSelectionIndex return if (selectedIndex >= 0 && selectedIndex < listDevWorkspaces.itemsCount) { - listDevWorkspaces.model.getElementAt(selectedIndex) + listDevWorkspaces.model.getElementAt(selectedIndex).workspace } else { null } @@ -451,14 +499,15 @@ class DevSpacesWorkspacesStepView( return devSpacesContext.isWorkspaceActive(workspace) } - class DevWorkspaceListRenderer : ColoredListCellRenderer() { + class DevWorkspaceListRenderer : ColoredListCellRenderer() { override fun customizeCellRenderer( - list: JList, - devWorkspace: DevWorkspace, + list: JList, + item: DevWorkspaceListItem, index: Int, selected: Boolean, hasFocus: Boolean ) { + val devWorkspace = item.workspace val icon = DevSpacesIcons.getWorkspacePhaseIcon(devWorkspace.phase) ?: AllIcons.Empty setIcon(icon) @@ -466,6 +515,7 @@ class DevSpacesWorkspacesStepView( font = JBFont.h4().asPlain() append(devWorkspace.displayName, SimpleTextAttributes.REGULAR_ATTRIBUTES) + append(" · ${item.editorLabel}", SimpleTextAttributes.GRAYED_ATTRIBUTES) if (hasMultipleWorkspaces(list.model)) { val fm = getFontMetrics(font) val maxNameWidth = calculateMaxNameWidth(list.model, fm) @@ -475,22 +525,22 @@ class DevSpacesWorkspacesStepView( } } - private fun calculateMaxNameWidth(listModel: ListModel, fm: FontMetrics): Int { + private fun calculateMaxNameWidth(listModel: ListModel, fm: FontMetrics): Int { var maxWidth = 0 for (i in 0 until listModel.size) { - val nameWidth = fm.stringWidth(listModel.getElementAt(i).name) + val nameWidth = fm.stringWidth(listModel.getElementAt(i).workspace.name) if (nameWidth > maxWidth) maxWidth = nameWidth } return maxWidth } - private fun hasMultipleWorkspaces(listModel: ListModel): Boolean { + private fun hasMultipleWorkspaces(listModel: ListModel): Boolean { if (listModel.size <= 1) return false - val firstNamespace = listModel.getElementAt(0).namespace + val firstNamespace = listModel.getElementAt(0).workspace.namespace return (1 until listModel.size) .asSequence() - .map { listModel.getElementAt(it).namespace } + .map { listModel.getElementAt(it).workspace.namespace } .any { it != firstNamespace } } } @@ -500,7 +550,8 @@ class DevSpacesWorkspacesStepView( } override fun dispose() { - watchManager?.stop() + watchManager?.dispose() + watchManager = null } inner class DevWorkspaceSelection : ListSelectionListener { @@ -512,53 +563,147 @@ class DevSpacesWorkspacesStepView( private class WorkspacesWatch( private val client: ApiClient, - private val workspacesDataModel: DefaultListModel + private val workspacesDataModel: DefaultListModel ) { private val devWorkspaces = DevWorkspaces(client) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Volatile + var templateMapsByNamespace: Map>> = emptyMap() + private val templatesUnavailableNamespaces: MutableSet = ConcurrentHashMap.newKeySet() + private val templateFetchInFlight: ConcurrentHashMap = ConcurrentHashMap() + + fun seedTemplateCache( + mapsByNamespace: Map>>, + unavailableNamespaces: Set + ) { + templateMapsByNamespace = mapsByNamespace + templatesUnavailableNamespaces.clear() + templatesUnavailableNamespaces.addAll(unavailableNamespaces) + } + private val watchManager = DevWorkspaceWatchManager( createWatcher = { ns, latestResourceVersion -> devWorkspaces.createWatcher(ns, latestResourceVersion = latestResourceVersion) }, - createFilter = { ns -> - devWorkspaces.createIdeaEditorFilter(ns) + createFilter = { _ -> + { _ -> true } }, listener = object : DevWorkspaceListener { override fun onAdded(dw: DevWorkspace) { - onUpdated(dw) + onAddedWatch(dw) } override fun onUpdated(dw: DevWorkspace) { runInEdt { - val idx = indexOfFirst { it.name == dw.name && it.namespace == dw.namespace } - if (idx == -1) { - val index = findInsertIndex(dw) - workspacesDataModel.add(index, dw) + val idx = indexOfFirst { it.workspace == dw } + if (idx != -1) { + // Phase/status updates do not change the editor. Keep the previously + // resolved label so template-based JetBrains does not flip (CRW-11897). + val item = DevWorkspaceListItem(dw, workspacesDataModel[idx].editorLabel) + workspacesDataModel.set(idx, item) } else { - workspacesDataModel.set(idx, dw) + // Missed ADDED (reconnect gap) — resolve like a new workspace. + onAddedWatch(dw) } } } override fun onDeleted(dw: DevWorkspace) { runInEdt { - val idx = indexOfFirst { it.namespace == dw.namespace && it.name == dw.name } + val idx = indexOfFirst { it.workspace == dw } if (idx >= 0) { workspacesDataModel.remove(idx) } } } - private fun findInsertIndex(dw: DevWorkspace): Int { + private fun onAddedWatch(dw: DevWorkspace) { + val ns = dw.namespace + val templateMap = templateMapsByNamespace[ns] ?: emptyMap() + val resolved = devWorkspaces.resolveEditorLabel(dw, templateMap) + + if (resolved != "Unknown") { + runInEdt { + insertOrUpdate(dw, resolved) + } + return + } + + // Namespace negatively cached — show Unknown immediately. + if (ns in templatesUnavailableNamespaces) { + runInEdt { + insertOrUpdate(dw, "Unknown") + } + return + } + + // Insert Unknown immediately, then background fetch on cache miss. + runInEdt { + insertOrUpdate(dw, "Unknown") + } + backgroundFetchTemplatesAndPatch(dw) + } + + private fun insertOrUpdate(dw: DevWorkspace, editorLabel: String) { + val idx = indexOfFirst { it.workspace == dw } + val item = DevWorkspaceListItem(dw, editorLabel) + if (idx == -1) { + val index = findInsertIndex(item) + workspacesDataModel.add(index, item) + } else { + workspacesDataModel.set(idx, item) + } + } + + private fun backgroundFetchTemplatesAndPatch(dw: DevWorkspace) { + val ns = dw.namespace + // Atomic coalesce: only one in-flight fetch per namespace. + scope.launch { + val thisJob = coroutineContext[Job]!! + if (templateFetchInFlight.putIfAbsent(ns, thisJob) != null) { + return@launch + } + try { + val load = devWorkspaces.loadTemplateMap(ns) + if (load.unavailable) { + templatesUnavailableNamespaces.add(ns) + return@launch + } + templateMapsByNamespace = templateMapsByNamespace + (ns to load.map) + templatesUnavailableNamespaces.remove(ns) + val freshLabel = devWorkspaces.resolveEditorLabel(dw, load.map) + if (freshLabel != "Unknown") { + runInEdt { + val idx = indexOfFirst { it.workspace == dw } + if (idx != -1) { + workspacesDataModel.set(idx, DevWorkspaceListItem(dw, freshLabel)) + } + } + } + } finally { + templateFetchInFlight.remove(ns, thisJob) + } + } + } + + private fun findInsertIndex(item: DevWorkspaceListItem): Int { + val dw = item.workspace val n = workspacesDataModel.size val groupStart = (0 until n).firstOrNull { - workspacesDataModel[it].namespace >= dw.namespace + workspacesDataModel[it].workspace.namespace >= dw.namespace } ?: n - val insertIndex = (groupStart until n).firstOrNull { - workspacesDataModel[it].namespace == dw.namespace && workspacesDataModel[it].name >= dw.name + val insertIndex = (groupStart until n).firstOrNull { i -> + val existing = workspacesDataModel[i].workspace + existing.namespace == dw.namespace && existing.name >= dw.name } ?: run { var endOfGroup = groupStart - while (endOfGroup < n && workspacesDataModel[endOfGroup].namespace == dw.namespace) endOfGroup++ + while (endOfGroup < n && + workspacesDataModel[endOfGroup].workspace.namespace == dw.namespace + ) { + endOfGroup++ + } endOfGroup } @@ -567,7 +712,7 @@ class DevSpacesWorkspacesStepView( } ) - private fun indexOfFirst(predicate: (DevWorkspace) -> Boolean): Int { + private fun indexOfFirst(predicate: (DevWorkspaceListItem) -> Boolean): Int { for (i in 0 until workspacesDataModel.size()) { if (predicate(workspacesDataModel.get(i))) return i } @@ -580,6 +725,14 @@ class DevSpacesWorkspacesStepView( fun stop() { watchManager.stop() + // Cancel in-flight template fetches for this watch cycle; keep scope for restart. + templateFetchInFlight.values.forEach { it.cancel() } + templateFetchInFlight.clear() + } + + fun dispose() { + stop() + scope.cancel() } } } \ No newline at end of file diff --git a/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspacesTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspacesTest.kt index 787133e4..af9044f2 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspacesTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspacesTest.kt @@ -15,6 +15,7 @@ import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.openapi.ApiException import io.kubernetes.client.openapi.apis.CustomObjectsApi import io.mockk.* +import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -235,6 +236,224 @@ class DevWorkspacesTest { assert(!devWorkspaces.isIdeaEditorBased(dw, emptyMap())) } + @Test + fun `#list includes non-Idea workspaces`() { + // given + mockListDevWorkspaces( + listOf( + createDevWorkspaceItem("idea-workspace", "eclipse/che-idea-server/latest"), + createDevWorkspaceItem("code-workspace", "eclipse/che-code/latest") + ) + ) + mockListDevWorkspaceTemplates(emptyList()) + + // when + val workspaces = devWorkspaces.list(namespace) + + // then + assertThat(workspaces).hasSize(2) + assertThat(workspaces.map { it.name }) + .containsExactlyInAnyOrder("idea-workspace", "code-workspace") + } + + @Test + fun `#listWithResult resolves labels for Idea and non-Idea workspaces`() { + // given + mockListDevWorkspaces( + listOf( + createDevWorkspaceItem("idea-workspace", "eclipse/che-idea-server/latest"), + createDevWorkspaceItem("code-workspace", "eclipse/che-code/latest") + ) + ) + mockListDevWorkspaceTemplates(emptyList()) + + // when + val result = devWorkspaces.listWithResult(namespace) + + // then + assertThat(result.items).hasSize(2) + assertThat(result.items.first { it.workspace.name == "idea-workspace" }.editorLabel) + .isEqualTo("JetBrains") + assertThat(result.items.first { it.workspace.name == "code-workspace" }.editorLabel) + .isEqualTo("eclipse") + } + + @Test + fun `#listWithResult treats unauthorized templates list as unknown label without throwing`() { + listWithResultTemplatesIgnored(ApiException(401, "Unauthorized")) + } + + @Test + fun `#listWithResult treats forbidden templates list as unknown label without throwing`() { + listWithResultTemplatesIgnored(ApiException(403, "Forbidden")) + } + + @Test + fun `#listWithResult treats not-found templates list as unknown label without throwing`() { + listWithResultTemplatesIgnored(ApiException(404, "Not Found")) + } + + @Test + fun `#listWithResult resolves template-based JetBrains workspace`() { + // given — no che-editor annotation, but a template with an idea-server volume + mockListDevWorkspaces(listOf(createDevWorkspaceItem("template-workspace", null))) + mockListDevWorkspaceTemplates( + listOf( + mapOf( + "metadata" to mapOf( + "name" to "template-workspace-template", + "namespace" to namespace, + "ownerReferences" to listOf( + mapOf( + "apiVersion" to "workspace.devfile.io/v1alpha2", + "kind" to "DevWorkspace", + "uid" to "test-uid" + ) + ) + ), + "spec" to mapOf( + "components" to listOf( + mapOf("volume" to mapOf("name" to "idea-server")) + ) + ) + ) + ) + ) + + // when + val result = devWorkspaces.listWithResult(namespace) + + // then + assertThat(result.items).hasSize(1) + assertThat(result.items[0].editorLabel).isEqualTo("JetBrains") + } + + @Test + fun `#listWithResult includes templateMap in result`() { + // given + mockListDevWorkspaces(listOf(createDevWorkspaceItem("template-workspace", null))) + mockListDevWorkspaceTemplates( + listOf( + mapOf( + "metadata" to mapOf( + "name" to "template-workspace-template", + "namespace" to namespace, + "ownerReferences" to listOf( + mapOf( + "apiVersion" to "workspace.devfile.io/v1alpha2", + "kind" to "DevWorkspace", + "uid" to "test-uid" + ) + ) + ), + "spec" to mapOf( + "components" to listOf( + mapOf("volume" to mapOf("name" to "idea-server")) + ) + ) + ) + ) + ) + + // when + val result = devWorkspaces.listWithResult(namespace) + + // then + assertThat(result.templateMap).isNotEmpty + assertThat(result.templateMap).containsKey("test-uid") + assertThat(result.templateMap["test-uid"]).hasSize(1) + assertThat(result.templatesUnavailable).isFalse() + } + + @Test + fun `#loadTemplateMap returns unavailable true for 401`() { + // given + mockListDevWorkspaceTemplatesThrows(ApiException(401, "Unauthorized")) + + // when + val load = devWorkspaces.loadTemplateMap(namespace) + + // then + assertThat(load.unavailable).isTrue() + assertThat(load.map).isEmpty() + } + + @Test + fun `#loadTemplateMap returns unavailable true for 403`() { + // given + mockListDevWorkspaceTemplatesThrows(ApiException(403, "Forbidden")) + + // when + val load = devWorkspaces.loadTemplateMap(namespace) + + // then + assertThat(load.unavailable).isTrue() + assertThat(load.map).isEmpty() + } + + @Test + fun `#loadTemplateMap returns unavailable true for 404`() { + // given + mockListDevWorkspaceTemplatesThrows(ApiException(404, "Not Found")) + + // when + val load = devWorkspaces.loadTemplateMap(namespace) + + // then + assertThat(load.unavailable).isTrue() + assertThat(load.map).isEmpty() + } + + @Test + fun `#loadTemplateMap returns available with map on success`() { + // given + mockListDevWorkspaceTemplates( + listOf( + mapOf( + "metadata" to mapOf( + "name" to "template-workspace-template", + "namespace" to namespace, + "ownerReferences" to listOf( + mapOf( + "apiVersion" to "workspace.devfile.io/v1alpha2", + "kind" to "DevWorkspace", + "uid" to "test-uid" + ) + ) + ), + "spec" to mapOf( + "components" to listOf( + mapOf("volume" to mapOf("name" to "idea-server")) + ) + ) + ) + ) + ) + + // when + val load = devWorkspaces.loadTemplateMap(namespace) + + // then + assertThat(load.unavailable).isFalse() + assertThat(load.map).isNotEmpty + assertThat(load.map).containsKey("test-uid") + } + + private fun listWithResultTemplatesIgnored(exception: ApiException) { + // given + mockListDevWorkspaces(listOf(createDevWorkspaceItem("plain-workspace", null))) + mockListDevWorkspaceTemplatesThrows(exception) + + // when + val result = devWorkspaces.listWithResult(namespace) + + // then + assertThat(result.items).hasSize(1) + assertThat(result.items[0].editorLabel).isEqualTo("Unknown") + assertThat(result.templateMap).isEmpty() + assertThat(result.templatesUnavailable).isTrue() + } + // Helper methods private fun mockGetDevWorkspace(devWorkspace: Any) { every { @@ -264,6 +483,71 @@ class DevWorkspacesTest { } } + private fun mockListDevWorkspaces(items: List>) { + every { + anyConstructed().listNamespacedCustomObject( + "workspace.devfile.io", + "v1alpha2", + namespace, + "devworkspaces" + ) + } returns mockk { + every { execute() } returns mapOf( + "metadata" to mapOf("resourceVersion" to "1"), + "items" to items + ) + } + } + + private fun mockListDevWorkspaceTemplates(items: List>) { + every { + anyConstructed().listNamespacedCustomObject( + "workspace.devfile.io", + "v1alpha2", + namespace, + "devworkspacetemplates" + ) + } returns mockk { + every { execute() } returns mapOf("items" to items) + } + } + + private fun mockListDevWorkspaceTemplatesThrows(exception: ApiException) { + every { + anyConstructed().listNamespacedCustomObject( + "workspace.devfile.io", + "v1alpha2", + namespace, + "devworkspacetemplates" + ) + } returns mockk { + every { execute() } throws exception + } + } + + private fun createDevWorkspaceItem(name: String, cheEditor: String?): Map { + val annotations = if (cheEditor != null) { + mapOf("che.eclipse.org/che-editor" to cheEditor) + } else { + emptyMap() + } + return mapOf( + "metadata" to mapOf( + "name" to name, + "namespace" to namespace, + "uid" to "test-uid", + "annotations" to annotations, + "labels" to mapOf("kubernetes.io/metadata.name" to name) + ), + "spec" to mapOf( + "started" to true + ), + "status" to mapOf( + "phase" to "Running" + ) + ) + } + private fun mockPatchDevWorkspace(callBuilder: okhttp3.Call) { every { anyConstructed().patchNamespacedCustomObject( diff --git a/src/test/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePodsTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePodsTest.kt index e13a7c3c..b24a31c7 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePodsTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePodsTest.kt @@ -30,6 +30,8 @@ import io.mockk.slot import io.mockk.unmockkAll import io.mockk.unmockkConstructor import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -551,6 +553,192 @@ class DevWorkspacePodsTest { unmockkConstructor(CoreV1Api::class) } + @Test + fun `#exec onError after onOpen completes with one failure and shuts down client once`() = runBlocking { + // given — ContainerAwareExec calls onOpen then onError (mid-stream error) + mockkConstructor(ContainerAwareExec::class) + val onErrorCalled = java.util.concurrent.atomic.AtomicBoolean() + val fakeHandle = ContainerAwareExec.ExecHandle( + future = java.util.concurrent.CompletableFuture.completedFuture(0), + job = mockk(relaxed = true) + ) + every { + anyConstructed().containerAwareExec( + any(), any(), any(), any(), any(), any(), any(), any(), any() + ) + } answers { + @Suppress("UNCHECKED_CAST") + val onOpen = it.invocation.args[4] as java.util.function.Consumer + val onError = it.invocation.args[6] as java.util.function.BiConsumer + val io = IOTrio() + io.stdout = ByteArrayInputStream(ByteArray(0)) + io.stderr = ByteArrayInputStream(ByteArray(0)) + io.stdin = mockk(relaxed = true) + onOpen.accept(io) + // Simulate mid-stream error after onOpen has fired + onError.accept(IOException("mid-stream connection reset"), io) + onErrorCalled.set(true) + fakeHandle + } + + mockkObject(ApiClientUtils) + val execClient = mockk(relaxed = true) + every { ApiClientUtils.cloneForExec(any()) } returns execClient + + val testPod = V1Pod().apply { + metadata = V1ObjectMeta().apply { + name = "test-pod" + namespace = "test-ns" + } + } + + // when / then + assertThatThrownBy { + runBlocking { + pods.exec( + pod = testPod, + command = arrayOf("echo"), + container = "test-container" + ) + } + }.isInstanceOf(IOException::class.java) + + // exactly one failure (not double-completed) + assertThat(onErrorCalled).isTrue() + + // client shut down at least once (joiner finally block handles it) + verify(atLeast = 1) { + execClient.httpClient.dispatcher.executorService.shutdownNow() + } + verify(atLeast = 1) { + execClient.httpClient.connectionPool.evictAll() + } + } + + private class CloseTrackingInputStream(private val delegate: java.io.InputStream) : java.io.InputStream() { + var closed = false + private set + override fun read(): Int = delegate.read() + override fun close() { closed = true; delegate.close() } + } + + @Test + fun `#exec closes stdout and stderr on cancellation`() = runBlocking { + // given — ContainerAwareExec returns a fake process with close-tracking streams + val stdout = CloseTrackingInputStream(ByteArrayInputStream("data".toByteArray())) + val stderr = CloseTrackingInputStream(ByteArrayInputStream(ByteArray(0))) + val fakeProcess = mockk(relaxed = true).also { + every { it.inputStream } returns stdout + every { it.errorStream } returns stderr + every { it.outputStream } returns mockk(relaxed = true) + every { it.isAlive } returns true + } + + mockkConstructor(ContainerAwareExec::class) + val fakeHandle = ContainerAwareExec.ExecHandle( + future = java.util.concurrent.CompletableFuture.completedFuture(0), + job = mockk(relaxed = true) + ) + val onOpenReady = CompletableDeferred() + every { + anyConstructed().containerAwareExec( + any(), any(), any(), any(), any(), any(), any(), any(), any() + ) + } answers { + @Suppress("UNCHECKED_CAST") + val onOpen = it.invocation.args[4] as java.util.function.Consumer + val io = IOTrio() + io.stdout = fakeProcess.inputStream + io.stderr = fakeProcess.errorStream + io.stdin = fakeProcess.outputStream + onOpen.accept(io) + onOpenReady.complete(Unit) + fakeHandle + } + + mockkObject(ApiClientUtils) + val execClient = mockk(relaxed = true) + every { ApiClientUtils.cloneForExec(any()) } returns execClient + + val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob()) + val testPod = V1Pod().apply { + metadata = V1ObjectMeta().apply { + name = "test-pod" + namespace = "test-ns" + } + } + + // when — launch exec, wait for onOpen, then cancel + val job = scope.launch { + pods.exec( + pod = testPod, + command = arrayOf("echo"), + container = "test-container", + timeout = 60 + ) + } + onOpenReady.await() + job.cancel() + job.join() + + // then — stdout and stderr should be closed + assertThat(stdout.closed).isTrue() + assertThat(stderr.closed).isTrue() + } + + @Test + fun `#exec fails when error occurs before onOpen`() = runBlocking { + // given — ContainerAwareExec calls onError without onOpen (connection failed before ready) + + mockkConstructor(ContainerAwareExec::class) + val fakeHandle = ContainerAwareExec.ExecHandle( + future = java.util.concurrent.CompletableFuture.completedFuture(0), + job = mockk(relaxed = true) + ) + every { + anyConstructed().containerAwareExec( + any(), any(), any(), any(), any(), any(), any(), any(), any() + ) + } answers { + @Suppress("UNCHECKED_CAST") + val onError = it.invocation.args[6] as java.util.function.BiConsumer + val io = IOTrio() + io.stdout = ByteArrayInputStream(ByteArray(0)) + io.stderr = ByteArrayInputStream(ByteArray(0)) + io.stdin = mockk(relaxed = true) + // Simulate error callback fired without onOpen (connection failed before ready) + onError.accept(IOException("connection refused before onOpen"), io) + fakeHandle + } + + mockkObject(ApiClientUtils) + val execClient = mockk(relaxed = true) + every { ApiClientUtils.cloneForExec(any()) } returns execClient + + val testPod = V1Pod().apply { + metadata = V1ObjectMeta().apply { + name = "test-pod" + namespace = "test-ns" + } + } + + // when / then + assertThatThrownBy { + runBlocking { + pods.exec( + pod = testPod, + command = arrayOf("echo"), + container = "test-container" + ) + } + }.isInstanceOf(IOException::class.java) + + verify { + execClient.httpClient.dispatcher.executorService.shutdownNow() + execClient.httpClient.connectionPool.evictAll() + } + } + @Test fun `#exec cancels cleanly when checkCancelled throws`() = runBlocking { // given — ContainerAwareExec returns a fake process that produces data @@ -713,4 +901,268 @@ class DevWorkspacePodsTest { execClient.httpClient.connectionPool.evictAll() } } + + @Test + fun `#exec throws IOException when process exits non-zero`() = runBlocking { + // given + mockkConstructor(ContainerAwareExec::class) + val fakeHandle = ContainerAwareExec.ExecHandle( + future = java.util.concurrent.CompletableFuture.completedFuture(0), + job = mockk(relaxed = true) + ) + every { + anyConstructed().containerAwareExec( + any(), any(), any(), any(), any(), any(), any(), any(), any() + ) + } answers { + @Suppress("UNCHECKED_CAST") + val onClosed = it.invocation.args[5] as java.util.function.BiConsumer + val onOpen = it.invocation.args[4] as java.util.function.Consumer + val io = IOTrio() + io.stdout = ByteArrayInputStream("stdout".toByteArray()) + io.stderr = ByteArrayInputStream(ByteArray(0)) + io.stdin = mockk(relaxed = true) + onOpen.accept(io) + onClosed.accept(1, io) + fakeHandle + } + + mockkObject(ApiClientUtils) + val execClient = mockk(relaxed = true) + every { ApiClientUtils.cloneForExec(any()) } returns execClient + + val testPod = V1Pod().apply { + metadata = V1ObjectMeta().apply { + name = "test-pod" + namespace = "test-ns" + } + } + + // when / then + assertThatThrownBy { + runBlocking { + pods.exec( + pod = testPod, + command = arrayOf("echo"), + container = "test-container", + timeout = 60 + ) + } + }.isInstanceOf(IOException::class.java) + .hasMessageContaining("exit code 1") + } + + @Test + fun `#exec throws IOException when exec times out`() = runBlocking { + // given + mockkConstructor(ContainerAwareExec::class) + val fakeHandle = ContainerAwareExec.ExecHandle( + future = java.util.concurrent.CompletableFuture.completedFuture(0), + job = mockk(relaxed = true) + ) + every { + anyConstructed().containerAwareExec( + any(), any(), any(), any(), any(), any(), any(), any(), any() + ) + } answers { + @Suppress("UNCHECKED_CAST") + val onClosed = it.invocation.args[5] as java.util.function.BiConsumer + val onOpen = it.invocation.args[4] as java.util.function.Consumer + val io = IOTrio() + io.stdout = ByteArrayInputStream("stdout".toByteArray()) + io.stderr = ByteArrayInputStream(ByteArray(0)) + io.stdin = mockk(relaxed = true) + onOpen.accept(io) + onClosed.accept(Int.MAX_VALUE, io) + fakeHandle + } + + mockkObject(ApiClientUtils) + val execClient = mockk(relaxed = true) + every { ApiClientUtils.cloneForExec(any()) } returns execClient + + val testPod = V1Pod().apply { + metadata = V1ObjectMeta().apply { + name = "test-pod" + namespace = "test-ns" + } + } + + // when / then + assertThatThrownBy { + runBlocking { + pods.exec( + pod = testPod, + command = arrayOf("echo"), + container = "test-container", + timeout = 60 + ) + } + }.isInstanceOf(IOException::class.java) + .hasMessageContaining("timed out") + } + + @Test + fun `#exec includes stderr in IOException when exit code is non-zero`() = runBlocking { + // given + mockkConstructor(ContainerAwareExec::class) + val fakeHandle = ContainerAwareExec.ExecHandle( + future = java.util.concurrent.CompletableFuture.completedFuture(0), + job = mockk(relaxed = true) + ) + every { + anyConstructed().containerAwareExec( + any(), any(), any(), any(), any(), any(), any(), any(), any() + ) + } answers { + @Suppress("UNCHECKED_CAST") + val onClosed = it.invocation.args[5] as java.util.function.BiConsumer + val onOpen = it.invocation.args[4] as java.util.function.Consumer + val io = IOTrio() + io.stdout = ByteArrayInputStream("stdout".toByteArray()) + io.stderr = ByteArrayInputStream("boom".toByteArray()) + io.stdin = mockk(relaxed = true) + onOpen.accept(io) + onClosed.accept(1, io) + fakeHandle + } + + mockkObject(ApiClientUtils) + val execClient = mockk(relaxed = true) + every { ApiClientUtils.cloneForExec(any()) } returns execClient + + val testPod = V1Pod().apply { + metadata = V1ObjectMeta().apply { + name = "test-pod" + namespace = "test-ns" + } + } + + // when / then + assertThatThrownBy { + runBlocking { + pods.exec( + pod = testPod, + command = arrayOf("echo"), + container = "test-container", + timeout = 60 + ) + } + }.isInstanceOf(IOException::class.java) + .hasMessageContaining("exit code 1") + .hasMessageContaining("boom") + } + + @Test + fun `#exec returns stdout when exit code is 0`() = runBlocking { + // given + mockkConstructor(ContainerAwareExec::class) + val fakeHandle = ContainerAwareExec.ExecHandle( + future = java.util.concurrent.CompletableFuture.completedFuture(0), + job = mockk(relaxed = true) + ) + every { + anyConstructed().containerAwareExec( + any(), any(), any(), any(), any(), any(), any(), any(), any() + ) + } answers { + @Suppress("UNCHECKED_CAST") + val onClosed = it.invocation.args[5] as java.util.function.BiConsumer + val onOpen = it.invocation.args[4] as java.util.function.Consumer + val io = IOTrio() + io.stdout = ByteArrayInputStream("hello world".toByteArray()) + io.stderr = ByteArrayInputStream(ByteArray(0)) + io.stdin = mockk(relaxed = true) + onOpen.accept(io) + onClosed.accept(0, io) + fakeHandle + } + + mockkObject(ApiClientUtils) + val execClient = mockk(relaxed = true) + every { ApiClientUtils.cloneForExec(any()) } returns execClient + + val testPod = V1Pod().apply { + metadata = V1ObjectMeta().apply { + name = "test-pod" + namespace = "test-ns" + } + } + + // when / then + assertThat(pods.exec( + pod = testPod, + command = arrayOf("echo"), + container = "test-container", + timeout = 60 + )).isEqualTo("hello world") + } + + @Test + fun `#exec fails when stdout stream errors even if exit code is 0`() = runBlocking { + // given — stdout delivers partial data then throws (no clean EOF) + mockkConstructor(ContainerAwareExec::class) + val fakeHandle = ContainerAwareExec.ExecHandle( + future = java.util.concurrent.CompletableFuture.completedFuture(0), + job = mockk(relaxed = true) + ) + every { + anyConstructed().containerAwareExec( + any(), any(), any(), any(), any(), any(), any(), any(), any() + ) + } answers { + @Suppress("UNCHECKED_CAST") + val onClosed = it.invocation.args[5] as java.util.function.BiConsumer + val onOpen = it.invocation.args[4] as java.util.function.Consumer + val io = IOTrio() + io.stdout = ThrowingAfterDataInputStream("partial".toByteArray()) + io.stderr = ByteArrayInputStream(ByteArray(0)) + io.stdin = mockk(relaxed = true) + onOpen.accept(io) + onClosed.accept(0, io) + fakeHandle + } + + mockkObject(ApiClientUtils) + val execClient = mockk(relaxed = true) + every { ApiClientUtils.cloneForExec(any()) } returns execClient + + val testPod = V1Pod().apply { + metadata = V1ObjectMeta().apply { + name = "test-pod" + namespace = "test-ns" + } + } + + // when / then — must not return the partial buffer as success + assertThatThrownBy { + runBlocking { + pods.exec( + pod = testPod, + command = arrayOf("echo"), + container = "test-container", + timeout = 60 + ) + } + }.isInstanceOf(IOException::class.java) + .hasMessageContaining("stream closed before output was fully read") + } + + /** Returns all bytes once, then throws instead of EOF (-1). */ + private class ThrowingAfterDataInputStream(private val data: ByteArray) : java.io.InputStream() { + private var index = 0 + override fun read(): Int { + if (index < data.size) return data[index++].toInt() and 0xFF + throw IOException("connection reset") + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + if (len == 0) return 0 + if (index >= data.size) throw IOException("connection reset") + val n = minOf(len, data.size - index) + System.arraycopy(data, index, b, off, n) + index += n + return n + } + } } \ No newline at end of file diff --git a/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt index 6001cd34..d87b789a 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt @@ -88,6 +88,24 @@ class RemoteIDEServerTest { } } + @Test + fun `#waitServerReady fails fast when idea-server container is missing`() { + // given — refreshing the pod/container during the ready-wait finds no idea-server container + every { + remoteIDEServer["findContainer"]() + } throws IdeServerContainerNotFoundException("Workspace IDE container not found in the Pod: test-pod") + + // when + val exception = assertThrows { + runBlocking { + remoteIDEServer.waitServerReady(timeout = 30) + } + } + + // then — fails immediately instead of retrying until the ready timeout elapses + assertThat(exception.message).contains("Workspace IDE container not found") + } + @Test fun `#waitServerReady should NOT reach timeout and throw if server status has a join link but no projects`() { // given