Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.isServerContainerNotFound
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
// no idea-server container, don't offer "restart pod" (CRW-11897).
if (e.isServerContainerNotFound()) throw e
RemoteIDEServerStatus.empty()
}

Expand Down
37 changes: 37 additions & 0 deletions src/main/kotlin/com/redhat/devtools/gateway/DevSpacesIcons.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
package com.redhat.devtools.gateway

import com.intellij.openapi.util.IconLoader
import com.redhat.devtools.gateway.devworkspace.WorkspaceEditorKind
import javax.swing.Icon

object DevSpacesIcons {
Expand All @@ -25,6 +26,22 @@ object DevSpacesIcons {
private val WORKSPACE_TERMINATING = IconLoader.getIcon("/icons/stopping.svg", javaClass)
private val WORKSPACE_FAILED = IconLoader.getIcon("/icons/failed.svg", javaClass)

private val EDITOR_VSCODE = IconLoader.getIcon("/icons/editors/vscode.svg", javaClass)
private val EDITOR_INTELLIJ_IDEA = IconLoader.getIcon("/icons/editors/intellij-idea.svg", javaClass)
private val EDITOR_JETBRAINS = IconLoader.getIcon("/icons/editors/jetbrains.svg", javaClass)
private val EDITOR_PYCHARM = IconLoader.getIcon("/icons/editors/pycharm.svg", javaClass)
private val EDITOR_CLION = IconLoader.getIcon("/icons/editors/clion.svg", javaClass)
private val EDITOR_GOLAND = IconLoader.getIcon("/icons/editors/goland.svg", javaClass)
private val EDITOR_PHPSTORM = IconLoader.getIcon("/icons/editors/phpstorm.svg", javaClass)
private val EDITOR_RIDER = IconLoader.getIcon("/icons/editors/rider.svg", javaClass)
private val EDITOR_RUBYMINE = IconLoader.getIcon("/icons/editors/rubymine.svg", javaClass)
private val EDITOR_WEBSTORM = IconLoader.getIcon("/icons/editors/webstorm.svg", javaClass)
private val EDITOR_CHEMUXER = IconLoader.getIcon("/icons/editors/chemuxer.svg", javaClass)
private val EDITOR_HERDR = IconLoader.getIcon("/icons/editors/herdr.svg", javaClass)
private val EDITOR_KIRO = IconLoader.getIcon("/icons/editors/kiro.svg", javaClass)
private val EDITOR_WEB_TERMINAL = IconLoader.getIcon("/icons/editors/web-terminal.svg", javaClass)
private val EDITOR_UNKNOWN = IconLoader.getIcon("/icons/editors/unknown.svg", javaClass)

fun getWorkspacePhaseIcon(phase: String): Icon? {
/*
* mimics what the web frontend is displaying.
Expand All @@ -41,4 +58,24 @@ object DevSpacesIcons {
}
}

fun getEditorIcon(kind: WorkspaceEditorKind): Icon {
return when (kind) {
WorkspaceEditorKind.VSCODE -> EDITOR_VSCODE
WorkspaceEditorKind.INTELLIJ_IDEA -> EDITOR_INTELLIJ_IDEA
WorkspaceEditorKind.JETBRAINS -> EDITOR_JETBRAINS
WorkspaceEditorKind.PYCHARM -> EDITOR_PYCHARM
WorkspaceEditorKind.CLION -> EDITOR_CLION
WorkspaceEditorKind.GOLAND -> EDITOR_GOLAND
WorkspaceEditorKind.PHPSTORM -> EDITOR_PHPSTORM
WorkspaceEditorKind.RIDER -> EDITOR_RIDER
WorkspaceEditorKind.RUBYMINE -> EDITOR_RUBYMINE
WorkspaceEditorKind.WEBSTORM -> EDITOR_WEBSTORM
WorkspaceEditorKind.CHEMUXER -> EDITOR_CHEMUXER
WorkspaceEditorKind.HERDR -> EDITOR_HERDR
WorkspaceEditorKind.KIRO -> EDITOR_KIRO
WorkspaceEditorKind.WEB_TERMINAL -> EDITOR_WEB_TERMINAL
WorkspaceEditorKind.UNKNOWN -> EDITOR_UNKNOWN
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ package com.redhat.devtools.gateway.devworkspace
import com.google.gson.reflect.TypeToken
import com.intellij.openapi.diagnostic.thisLogger
import com.redhat.devtools.gateway.openshift.Utils
import com.redhat.devtools.gateway.openshift.isDevWorkspaceCrdMissing
import com.redhat.devtools.gateway.openshift.isRetryable
import com.redhat.devtools.gateway.openshift.shouldBeIgnored
import io.kubernetes.client.openapi.ApiClient
import io.kubernetes.client.openapi.ApiException
import io.kubernetes.client.openapi.apis.CustomObjectsApi
Expand All @@ -26,9 +26,23 @@ import kotlinx.coroutines.withTimeoutOrNull
import java.io.IOException
import java.util.concurrent.CancellationException

data class DevWorkspaceListItem(
val workspace: DevWorkspace,
val editor: WorkspaceEditorInfo,
)

data class DevWorkspaceListResult(
val items: List<DevWorkspace>,
val resourceVersion: String?
val items: List<DevWorkspaceListItem>,
val resourceVersion: String?,
val templates: Map<String, List<DevWorkspaceTemplate>> = emptyMap(),
// True when the template list request failed with 401/403/404 (error ignored), so templates are unavailable.
// An empty map alone does not imply this.
val templatesUnavailable: Boolean = false
)

data class Templates(
val map: Map<String, List<DevWorkspaceTemplate>>,
val unavailable: Boolean // true when 401/403/404
)

val DevWorkspace.cheEditor: String
Expand All @@ -40,8 +54,6 @@ class DevWorkspaces(private val client: ApiClient) {
private val customApi = CustomObjectsApi(client)

companion object {
private val CHE_EDITOR_ID_REGEX = Regex("che-.*-server", RegexOption.IGNORE_CASE)

const val FAILED: String = "Failed"
const val RUNNING: String = "Running"
const val STOPPED: String = "Stopped"
Expand All @@ -53,77 +65,41 @@ class DevWorkspaces(private val client: ApiClient) {

@Throws(ApiException::class)
fun listWithResult(namespace: String): DevWorkspaceListResult {
try {
val response = customApi.listNamespacedCustomObject(
val response = try {
customApi.listNamespacedCustomObject(
"workspace.devfile.io",
"v1alpha2",
namespace,
"devworkspaces"
).execute()

val devWorkspaceTemplateMap = getTemplateMap(namespace)
val dwItems = Utils.getValue(response, arrayOf("items")) as List<*>
val dwList = dwItems
.map { dwItem -> DevWorkspace.from(dwItem) }
.filter { isIdeaEditorBased(it, devWorkspaceTemplateMap) }
val lastResourceVersion = (Utils.getValue(response, arrayOf("metadata", "resourceVersion")) as String?)

return DevWorkspaceListResult(dwList, lastResourceVersion)
} 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.isSkippableNamespaceForDevWorkspaceListing(namespace)) {
thisLogger().info("Ignored: ${e.message}")
return DevWorkspaceListResult(emptyList(), null)
} else {
thisLogger().error("Kubernetes API error ${e.code}", e)
throw e
}
}

val templates = loadTemplates(namespace)
val dwItems = Utils.getValue(response, arrayOf("items")) as List<*>
val dwList = dwItems
.map { dwItem -> DevWorkspace.from(dwItem) }
.map { dw -> DevWorkspaceListItem(dw, WorkspaceEditorInfoProvider.create(dw, templates.map)) }
val lastResourceVersion = (Utils.getValue(response, arrayOf("metadata", "resourceVersion")) as String?)

return DevWorkspaceListResult(
dwList,
lastResourceVersion,
templates.map,
templatesUnavailable = templates.unavailable
)
}

@Throws(ApiException::class)
fun list(namespace: String): List<DevWorkspace> {
return listWithResult(namespace).items
}

fun isIdeaEditorBased(devWorkspace: DevWorkspace, devWorkspaceTemplateMap: Map<String, List<DevWorkspaceTemplate>>): Boolean {
// Quick editor ID check
if (devWorkspace.cheEditor.split("/").any { CHE_EDITOR_ID_REGEX.matches(it) }) {
return true
}

// DevWorkspace Template check
val templates = devWorkspaceTemplateMap[devWorkspace.uid] ?: return false
return templates.any { template ->
@Suppress("UNCHECKED_CAST")
val components = template.components as? List<Any> ?: return@any false
components.any { component: Any ->
val map = component as? Map<*, *> ?: return@any false
val volume = map["volume"] as? Map<*, *>
// Check 'volume.name' first (v1alpha1), fallback to top-level 'name' (v1alpha2)
val name = volume?.get("name") as? String ?: map["name"] as? String
name.equals("idea-server", ignoreCase = true)
}
}
}

// Creates a filter for the Idea-based DevWorkspaces
fun createIdeaEditorFilter(
namespace: String
): (DevWorkspace) -> Boolean {
val templateMap = getTemplateMap(namespace)
return { dw: DevWorkspace ->
isIdeaEditorBased(dw, templateMap)
}
return listWithResult(namespace).items.map { it.workspace }
}

fun get(namespace: String, name: String): DevWorkspace {
Expand All @@ -137,8 +113,18 @@ 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>> {
/**
* Loads all DevWorkspaceTemplates for the given namespace and groups them by their owner reference UID.
*
* Queries the Kubernetes API for `devworkspacetemplates` resources in the specified namespace,
* parses each template, and builds a map from owner UID to the list of templates owned by that UID.
*
* If the API returns a 401/403/404, returns an empty map with `unavailable = true`.
*
* @param namespace the Kubernetes namespace to list templates from
* @return a [Templates] containing the UID-to-templates map and an availability flag
*/
fun loadTemplates(namespace: String): Templates {
try {
val dwTemplateList = customApi
.listNamespacedCustomObject(
Expand All @@ -150,7 +136,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 +145,10 @@ class DevWorkspaces(private val client: ApiClient) {
keySelector = { it.first }, // UID
valueTransform = { it.second } // DevWorkspaceTemplate
)
return Templates(map, unavailable = false)
} catch (e: ApiException) {
if (e.shouldBeIgnored()) {
return emptyMap()
if (e.isIgnorableTemplateListError()) {
return Templates(emptyMap(), unavailable = true)
}
thisLogger().info(e.message)
throw e
Expand Down Expand Up @@ -324,4 +311,33 @@ class DevWorkspaces(private val client: ApiClient) {
object : TypeToken<Watch.Response<Any>>() {}.type
)
}

/** Returns `true` if the given exception is ignorable when listing templates.
* Returns `false` otherwise.
* Template list failures with 401, 403, or 404 are silently degraded to an empty
* map with [Templates.unavailable] set to true.
*
* Note: 401 is ignorable for templates because templates are optional metadata
* (editor detection falls back to annotation). However, 401 for devworkspaces
* listing is NOT ignorable and rethrows — see [isSkippableNamespaceForDevWorkspaceListing].
*/
private fun ApiException.isIgnorableTemplateListError(): Boolean =
code == 401 || code == 403 || code == 404

/** Returns `true` if the given exception is skippable when listing devworkspaces
* for a specific namespace during multi-namespace scanning.
* Returns `false` otherwise.
* Skippable errors: CRD missing (404 with CRD-not-found response body),
* 403 (Forbidden), or plain 404 — the namespace has no DevSpaces/DevWorkspaces
* resources or access is denied for system namespaces in multi-namespace scans.
* Non-skippable: 401 (Unauthorized) propagates/rethrows, and other errors
* propagate normally.
*/
private fun ApiException.isSkippableNamespaceForDevWorkspaceListing(namespace: String): Boolean = when {
isDevWorkspaceCrdMissing() -> true
code == 403 -> true
code == 404 -> true
else -> false
}

}
Loading
Loading