Skip to content

Commit 5e6032a

Browse files
adietishcursoragent
andcommitted
fix: list all DevWorkspaces, handle inaccessible templates gracefully (CRW-11897)
Avoid Fatal IDE errors and hidden JetBrains workspaces when devworkspacetemplates list returns 401/403/404. Show editor labels, fail fast without idea-server container. Signed-off-by: Andre Dietisheim <adietish@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent fac59f8 commit 5e6032a

38 files changed

Lines changed: 2851 additions & 396 deletions

src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import com.redhat.devtools.gateway.server.RemoteIDEServer
2828
import com.redhat.devtools.gateway.server.RemoteIDEServerStatus
2929
import com.redhat.devtools.gateway.util.ProgressCountdown
3030
import com.redhat.devtools.gateway.util.isCancellationException
31+
import com.redhat.devtools.gateway.util.isServerContainerNotFound
3132
import com.redhat.devtools.gateway.view.ui.Dialogs
3233
import io.kubernetes.client.openapi.ApiClient
3334
import io.kubernetes.client.openapi.models.V1Pod
@@ -304,6 +305,8 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) {
304305
remoteIdeServer.apply { waitServerReady(checkCancelled) }.getStatus(checkCancelled)
305306
}.getOrElse { e ->
306307
if (e.isCancellationException()) throw e
308+
// no idea-server container, don't offer "restart pod" (CRW-11897).
309+
if (e.isServerContainerNotFound()) throw e
307310
RemoteIDEServerStatus.empty()
308311
}
309312

src/main/kotlin/com/redhat/devtools/gateway/DevSpacesIcons.kt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
package com.redhat.devtools.gateway
1313

1414
import com.intellij.openapi.util.IconLoader
15+
import com.redhat.devtools.gateway.devworkspace.WorkspaceEditorKind
1516
import javax.swing.Icon
1617

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

29+
private val EDITOR_VSCODE = IconLoader.getIcon("/icons/editors/vscode.svg", javaClass)
30+
private val EDITOR_INTELLIJ_IDEA = IconLoader.getIcon("/icons/editors/intellij-idea.svg", javaClass)
31+
private val EDITOR_JETBRAINS = IconLoader.getIcon("/icons/editors/jetbrains.svg", javaClass)
32+
private val EDITOR_PYCHARM = IconLoader.getIcon("/icons/editors/pycharm.svg", javaClass)
33+
private val EDITOR_CLION = IconLoader.getIcon("/icons/editors/clion.svg", javaClass)
34+
private val EDITOR_GOLAND = IconLoader.getIcon("/icons/editors/goland.svg", javaClass)
35+
private val EDITOR_PHPSTORM = IconLoader.getIcon("/icons/editors/phpstorm.svg", javaClass)
36+
private val EDITOR_RIDER = IconLoader.getIcon("/icons/editors/rider.svg", javaClass)
37+
private val EDITOR_RUBYMINE = IconLoader.getIcon("/icons/editors/rubymine.svg", javaClass)
38+
private val EDITOR_WEBSTORM = IconLoader.getIcon("/icons/editors/webstorm.svg", javaClass)
39+
private val EDITOR_CHEMUXER = IconLoader.getIcon("/icons/editors/chemuxer.svg", javaClass)
40+
private val EDITOR_HERDR = IconLoader.getIcon("/icons/editors/herdr.svg", javaClass)
41+
private val EDITOR_KIRO = IconLoader.getIcon("/icons/editors/kiro.svg", javaClass)
42+
private val EDITOR_WEB_TERMINAL = IconLoader.getIcon("/icons/editors/web-terminal.svg", javaClass)
43+
private val EDITOR_UNKNOWN = IconLoader.getIcon("/icons/editors/unknown.svg", javaClass)
44+
2845
fun getWorkspacePhaseIcon(phase: String): Icon? {
2946
/*
3047
* mimics what the web frontend is displaying.
@@ -41,4 +58,24 @@ object DevSpacesIcons {
4158
}
4259
}
4360

61+
fun getEditorIcon(kind: WorkspaceEditorKind): Icon {
62+
return when (kind) {
63+
WorkspaceEditorKind.VSCODE -> EDITOR_VSCODE
64+
WorkspaceEditorKind.INTELLIJ_IDEA -> EDITOR_INTELLIJ_IDEA
65+
WorkspaceEditorKind.JETBRAINS -> EDITOR_JETBRAINS
66+
WorkspaceEditorKind.PYCHARM -> EDITOR_PYCHARM
67+
WorkspaceEditorKind.CLION -> EDITOR_CLION
68+
WorkspaceEditorKind.GOLAND -> EDITOR_GOLAND
69+
WorkspaceEditorKind.PHPSTORM -> EDITOR_PHPSTORM
70+
WorkspaceEditorKind.RIDER -> EDITOR_RIDER
71+
WorkspaceEditorKind.RUBYMINE -> EDITOR_RUBYMINE
72+
WorkspaceEditorKind.WEBSTORM -> EDITOR_WEBSTORM
73+
WorkspaceEditorKind.CHEMUXER -> EDITOR_CHEMUXER
74+
WorkspaceEditorKind.HERDR -> EDITOR_HERDR
75+
WorkspaceEditorKind.KIRO -> EDITOR_KIRO
76+
WorkspaceEditorKind.WEB_TERMINAL -> EDITOR_WEB_TERMINAL
77+
WorkspaceEditorKind.UNKNOWN -> EDITOR_UNKNOWN
78+
}
79+
}
80+
4481
}

src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaces.kt

Lines changed: 85 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ package com.redhat.devtools.gateway.devworkspace
1414
import com.google.gson.reflect.TypeToken
1515
import com.intellij.openapi.diagnostic.thisLogger
1616
import com.redhat.devtools.gateway.openshift.Utils
17+
import com.redhat.devtools.gateway.openshift.isDevWorkspaceCrdMissing
1718
import com.redhat.devtools.gateway.openshift.isRetryable
18-
import com.redhat.devtools.gateway.openshift.shouldBeIgnored
1919
import io.kubernetes.client.openapi.ApiClient
2020
import io.kubernetes.client.openapi.ApiException
2121
import io.kubernetes.client.openapi.apis.CustomObjectsApi
@@ -26,9 +26,23 @@ import kotlinx.coroutines.withTimeoutOrNull
2626
import java.io.IOException
2727
import java.util.concurrent.CancellationException
2828

29+
data class DevWorkspaceListItem(
30+
val workspace: DevWorkspace,
31+
val editor: WorkspaceEditorInfo,
32+
)
33+
2934
data class DevWorkspaceListResult(
30-
val items: List<DevWorkspace>,
31-
val resourceVersion: String?
35+
val items: List<DevWorkspaceListItem>,
36+
val resourceVersion: String?,
37+
val templates: Map<String, List<DevWorkspaceTemplate>> = emptyMap(),
38+
// True when the template list request failed with 401/403/404 (error ignored), so templates are unavailable.
39+
// An empty map alone does not imply this.
40+
val templatesUnavailable: Boolean = false
41+
)
42+
43+
data class Templates(
44+
val map: Map<String, List<DevWorkspaceTemplate>>,
45+
val unavailable: Boolean // true when 401/403/404
3246
)
3347

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

4256
companion object {
43-
private val CHE_EDITOR_ID_REGEX = Regex("che-.*-server", RegexOption.IGNORE_CASE)
44-
4557
const val FAILED: String = "Failed"
4658
const val RUNNING: String = "Running"
4759
const val STOPPED: String = "Stopped"
@@ -53,77 +65,41 @@ class DevWorkspaces(private val client: ApiClient) {
5365

5466
@Throws(ApiException::class)
5567
fun listWithResult(namespace: String): DevWorkspaceListResult {
56-
try {
57-
val response = customApi.listNamespacedCustomObject(
68+
val response = try {
69+
customApi.listNamespacedCustomObject(
5870
"workspace.devfile.io",
5971
"v1alpha2",
6072
namespace,
6173
"devworkspaces"
6274
).execute()
63-
64-
val devWorkspaceTemplateMap = getTemplateMap(namespace)
65-
val dwItems = Utils.getValue(response, arrayOf("items")) as List<*>
66-
val dwList = dwItems
67-
.map { dwItem -> DevWorkspace.from(dwItem) }
68-
.filter { isIdeaEditorBased(it, devWorkspaceTemplateMap) }
69-
val lastResourceVersion = (Utils.getValue(response, arrayOf("metadata", "resourceVersion")) as String?)
70-
71-
return DevWorkspaceListResult(dwList, lastResourceVersion)
7275
} catch (e: ApiException) {
73-
thisLogger().info(e.message)
74-
75-
return when (e.code) {
76-
403, 404 -> {
77-
// There might be some namespaces (OpenShift projects) in which the user cannot list resource "devworkspaces"
78-
// e.g. "openshift-virtualization-os-images" on Red Hat Dev Sandbox, or the given cluster doesn't have
79-
// the RedHat DevSpaces operator installed on it, etc.
80-
//
81-
// It doesn't make sense to show an error to the user in such cases,
82-
// so let's skip it silently.
83-
DevWorkspaceListResult(emptyList(), null)
84-
}
85-
else -> {
86-
thisLogger().error("Kubernetes API error ${e.code}", e)
87-
throw e
88-
}
76+
if (e.isSkippableNamespaceForDevWorkspaceListing(namespace)) {
77+
thisLogger().info("Ignored: ${e.message}")
78+
return DevWorkspaceListResult(emptyList(), null)
79+
} else {
80+
thisLogger().error("Kubernetes API error ${e.code}", e)
81+
throw e
8982
}
9083
}
84+
85+
val templates = loadTemplates(namespace)
86+
val dwItems = Utils.getValue(response, arrayOf("items")) as List<*>
87+
val dwList = dwItems
88+
.map { dwItem -> DevWorkspace.from(dwItem) }
89+
.map { dw -> DevWorkspaceListItem(dw, WorkspaceEditorInfoProvider.create(dw, templates.map)) }
90+
val lastResourceVersion = (Utils.getValue(response, arrayOf("metadata", "resourceVersion")) as String?)
91+
92+
return DevWorkspaceListResult(
93+
dwList,
94+
lastResourceVersion,
95+
templates.map,
96+
templatesUnavailable = templates.unavailable
97+
)
9198
}
9299

93100
@Throws(ApiException::class)
94101
fun list(namespace: String): List<DevWorkspace> {
95-
return listWithResult(namespace).items
96-
}
97-
98-
fun isIdeaEditorBased(devWorkspace: DevWorkspace, devWorkspaceTemplateMap: Map<String, List<DevWorkspaceTemplate>>): Boolean {
99-
// Quick editor ID check
100-
if (devWorkspace.cheEditor.split("/").any { CHE_EDITOR_ID_REGEX.matches(it) }) {
101-
return true
102-
}
103-
104-
// DevWorkspace Template check
105-
val templates = devWorkspaceTemplateMap[devWorkspace.uid] ?: return false
106-
return templates.any { template ->
107-
@Suppress("UNCHECKED_CAST")
108-
val components = template.components as? List<Any> ?: return@any false
109-
components.any { component: Any ->
110-
val map = component as? Map<*, *> ?: return@any false
111-
val volume = map["volume"] as? Map<*, *>
112-
// Check 'volume.name' first (v1alpha1), fallback to top-level 'name' (v1alpha2)
113-
val name = volume?.get("name") as? String ?: map["name"] as? String
114-
name.equals("idea-server", ignoreCase = true)
115-
}
116-
}
117-
}
118-
119-
// Creates a filter for the Idea-based DevWorkspaces
120-
fun createIdeaEditorFilter(
121-
namespace: String
122-
): (DevWorkspace) -> Boolean {
123-
val templateMap = getTemplateMap(namespace)
124-
return { dw: DevWorkspace ->
125-
isIdeaEditorBased(dw, templateMap)
126-
}
102+
return listWithResult(namespace).items.map { it.workspace }
127103
}
128104

129105
fun get(namespace: String, name: String): DevWorkspace {
@@ -137,8 +113,18 @@ class DevWorkspaces(private val client: ApiClient) {
137113
return DevWorkspace.from(dwObj)
138114
}
139115

140-
// Returns a map of DW Owner UID tp list of DW Templates
141-
private fun getTemplateMap(namespace: String): Map<String, List<DevWorkspaceTemplate>> {
116+
/**
117+
* Loads all DevWorkspaceTemplates for the given namespace and groups them by their owner reference UID.
118+
*
119+
* Queries the Kubernetes API for `devworkspacetemplates` resources in the specified namespace,
120+
* parses each template, and builds a map from owner UID to the list of templates owned by that UID.
121+
*
122+
* If the API returns a 401/403/404, returns an empty map with `unavailable = true`.
123+
*
124+
* @param namespace the Kubernetes namespace to list templates from
125+
* @return a [Templates] containing the UID-to-templates map and an availability flag
126+
*/
127+
fun loadTemplates(namespace: String): Templates {
142128
try {
143129
val dwTemplateList = customApi
144130
.listNamespacedCustomObject(
@@ -150,7 +136,7 @@ class DevWorkspaces(private val client: ApiClient) {
150136
.execute()
151137

152138
val items = Utils.getValue(dwTemplateList, arrayOf("items")) as? List<*> ?: emptyList<Any>()
153-
return items
139+
val map = items
154140
.map { DevWorkspaceTemplate.from(it) }
155141
.flatMap { templ ->
156142
templ.ownerRefencesUids.map { uid -> uid to templ }
@@ -159,9 +145,10 @@ class DevWorkspaces(private val client: ApiClient) {
159145
keySelector = { it.first }, // UID
160146
valueTransform = { it.second } // DevWorkspaceTemplate
161147
)
148+
return Templates(map, unavailable = false)
162149
} catch (e: ApiException) {
163-
if (e.shouldBeIgnored()) {
164-
return emptyMap()
150+
if (e.isIgnorableTemplateListError()) {
151+
return Templates(emptyMap(), unavailable = true)
165152
}
166153
thisLogger().info(e.message)
167154
throw e
@@ -324,4 +311,33 @@ class DevWorkspaces(private val client: ApiClient) {
324311
object : TypeToken<Watch.Response<Any>>() {}.type
325312
)
326313
}
314+
315+
/** Returns `true` if the given exception is ignorable when listing templates.
316+
* Returns `false` otherwise.
317+
* Template list failures with 401, 403, or 404 are silently degraded to an empty
318+
* map with [Templates.unavailable] set to true.
319+
*
320+
* Note: 401 is ignorable for templates because templates are optional metadata
321+
* (editor detection falls back to annotation). However, 401 for devworkspaces
322+
* listing is NOT ignorable and rethrows — see [isSkippableNamespaceForDevWorkspaceListing].
323+
*/
324+
private fun ApiException.isIgnorableTemplateListError(): Boolean =
325+
code == 401 || code == 403 || code == 404
326+
327+
/** Returns `true` if the given exception is skippable when listing devworkspaces
328+
* for a specific namespace during multi-namespace scanning.
329+
* Returns `false` otherwise.
330+
* Skippable errors: CRD missing (404 with CRD-not-found response body),
331+
* 403 (Forbidden), or plain 404 — the namespace has no DevSpaces/DevWorkspaces
332+
* resources or access is denied for system namespaces in multi-namespace scans.
333+
* Non-skippable: 401 (Unauthorized) propagates/rethrows, and other errors
334+
* propagate normally.
335+
*/
336+
private fun ApiException.isSkippableNamespaceForDevWorkspaceListing(namespace: String): Boolean = when {
337+
isDevWorkspaceCrdMissing() -> true
338+
code == 403 -> true
339+
code == 404 -> true
340+
else -> false
341+
}
342+
327343
}

0 commit comments

Comments
 (0)