Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DevWorkspace>,
val resourceVersion: String?
val items: List<DevWorkspaceListItem>,
val resourceVersion: String?,
val templateMap: Map<String, List<DevWorkspaceTemplate>> = 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<String, List<DevWorkspaceTemplate>>,
val unavailable: Boolean // true when 401/403/404
)

val DevWorkspace.cheEditor: String
Expand Down Expand Up @@ -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<DevWorkspace> {
return listWithResult(namespace).items
return listWithResult(namespace).items.map { it.workspace }
}

fun resolveEditorLabel(
devWorkspace: DevWorkspace,
templateMap: Map<String, List<DevWorkspaceTemplate>>
): 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<String, List<DevWorkspaceTemplate>>): Boolean {
Expand All @@ -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",
Expand All @@ -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<String, List<DevWorkspaceTemplate>> {
// 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(
Expand All @@ -150,7 +177,7 @@ class DevWorkspaces(private val client: ApiClient) {
.execute()

val items = Utils.getValue(dwTemplateList, arrayOf("items")) as? List<*> ?: emptyList<Any>()
return items
val map = items
.map { DevWorkspaceTemplate.from(it) }
.flatMap { templ ->
templ.ownerRefencesUids.map { uid -> uid to templ }
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {

Expand Down Expand Up @@ -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<Unit>()
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<Unit>,
cont: CancellableContinuation<String>,
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())
Expand All @@ -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()
}
}
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading