Skip to content

Commit 727b816

Browse files
committed
feat: update conn wizard when kube config(s) are updated (#23558)
Generated by: gemini-cli Generated by: cursor Signed-off-by: Andre Dietisheim <[email protected]>
1 parent e907509 commit 727b816

File tree

15 files changed

+819
-202
lines changed

15 files changed

+819
-202
lines changed

build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import org.gradle.kotlin.dsl.dependencies
12
import org.jetbrains.changelog.Changelog
23
import org.jetbrains.changelog.markdownToHTML
34
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
@@ -35,7 +36,12 @@ dependencies {
3536
testImplementation("org.junit.platform:junit-platform-launcher:6.0.0")
3637
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.0.0")
3738
testImplementation("org.assertj:assertj-core:3.27.6")
39+
3840
testImplementation("io.mockk:mockk:1.14.6")
41+
testImplementation("io.mockk:mockk-agent-jvm:1.14.6")
42+
43+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
44+
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
3945

4046
// IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html
4147
intellijPlatform {

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ import com.redhat.devtools.gateway.openshift.kube.isUnauthorized
2828
import com.redhat.devtools.gateway.util.messageWithoutPrefix
2929
import com.redhat.devtools.gateway.view.ui.Dialogs
3030
import io.kubernetes.client.openapi.ApiException
31+
import io.kubernetes.client.util.KubeConfig
3132
import kotlinx.coroutines.CompletableDeferred
3233
import kotlinx.coroutines.ExperimentalCoroutinesApi
33-
import kotlinx.coroutines.runBlocking
3434
import kotlinx.coroutines.suspendCancellableCoroutine
3535
import javax.swing.JComponent
3636
import javax.swing.Timer
@@ -46,6 +46,8 @@ private const val DW_NAME = "dwName"
4646
*/
4747
class DevSpacesConnectionProvider : GatewayConnectionProvider {
4848

49+
private var clientFactory: OpenShiftClientFactory? = null
50+
4951
@OptIn(ExperimentalCoroutinesApi::class)
5052
@Suppress("UnstableApiUsage")
5153
override suspend fun connect(
@@ -183,7 +185,9 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider {
183185
val ctx = DevSpacesContext()
184186

185187
indicator.text2 = "Initializing Kubernetes connection…"
186-
ctx.client = OpenShiftClientFactory().create()
188+
val factory = OpenShiftClientFactory(KubeConfigBuilder())
189+
ctx.client = factory.create()
190+
clientFactory = factory
187191

188192
indicator.text2 = "Fetching DevWorkspace “$dwName” from namespace “$dwNamespace”…"
189193
ctx.devWorkspace = DevWorkspaces(ctx.client).get(dwNamespace, dwName)
@@ -225,7 +229,7 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider {
225229
private fun handleUnauthorizedError(err: ApiException): Boolean {
226230
if (!err.isUnauthorized()) return false
227231

228-
val tokenNote = if (KubeConfigBuilder.isTokenAuthUsed())
232+
val tokenNote = if (clientFactory?.isTokenAuthUsed() == true)
229233
"\n\nYou are using token-based authentication.\nUpdate your token in the kubeconfig file."
230234
else ""
231235

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,5 @@ import io.kubernetes.client.openapi.ApiClient
1717
class DevSpacesContext {
1818
lateinit var client: ApiClient
1919
lateinit var devWorkspace: DevWorkspace
20-
var activeWorkspaces = mutableSetOf<DevWorkspace>() // Global or companion-level variable
20+
var activeWorkspaces = mutableSetOf<DevWorkspace>()
2121
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright (c) 2025 Red Hat, Inc.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Red Hat, Inc. - initial API and implementation
11+
*/
12+
package com.redhat.devtools.gateway.kubeconfig
13+
14+
import kotlinx.coroutines.*
15+
import java.nio.file.*
16+
import java.util.concurrent.ConcurrentHashMap
17+
import kotlin.io.path.exists
18+
import kotlin.io.path.isRegularFile
19+
20+
class KubeconfigFileWatcher(
21+
private val scope: CoroutineScope,
22+
private val onFileChanged: (Path) -> Unit,
23+
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
24+
private val watchService: WatchService = FileSystems.getDefault().newWatchService()
25+
) {
26+
private val registeredKeys = ConcurrentHashMap<WatchKey, Path>()
27+
private val monitoredFiles = ConcurrentHashMap<Path, Unit>()
28+
private var watchJob: Job? = null
29+
30+
fun start() {
31+
watchJob = scope.launch(ioDispatcher) {
32+
while (isActive) {
33+
val key = watchService.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS)
34+
if (key == null) {
35+
delay(100)
36+
continue
37+
}
38+
val dir = registeredKeys[key] ?: continue
39+
40+
for (event in key.pollEvents()) {
41+
val relativePath = event.context() as? Path ?: continue
42+
val changedFile = dir.resolve(relativePath)
43+
44+
if (monitoredFiles.containsKey(changedFile)
45+
&& event.kind() != StandardWatchEventKinds.OVERFLOW) {
46+
onFileChanged(changedFile)
47+
}
48+
}
49+
key.reset()
50+
}
51+
}
52+
}
53+
54+
fun addFile(path: Path) {
55+
if (!path.exists()
56+
|| !path.isRegularFile()) {
57+
return
58+
}
59+
val parentDir = path.parent
60+
if (parentDir != null
61+
&& !monitoredFiles.containsKey(path)) {
62+
val watchKey = parentDir.register(watchService,
63+
StandardWatchEventKinds.ENTRY_CREATE,
64+
StandardWatchEventKinds.ENTRY_MODIFY,
65+
StandardWatchEventKinds.ENTRY_DELETE
66+
)
67+
registeredKeys[watchKey] = parentDir
68+
monitoredFiles[path] = Unit
69+
onFileChanged(path)
70+
}
71+
}
72+
73+
fun removeFile(path: Path) {
74+
monitoredFiles.remove(path)
75+
}
76+
77+
fun stop() {
78+
watchJob?.cancel()
79+
watchJob = null
80+
watchService.close()
81+
}
82+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/*
2+
* Copyright (c) 2025 Red Hat, Inc.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Red Hat, Inc. - initial API and implementation
11+
*/
12+
package com.redhat.devtools.gateway.kubeconfig
13+
14+
import com.intellij.openapi.diagnostic.thisLogger
15+
import com.redhat.devtools.gateway.openshift.kube.Cluster
16+
import com.redhat.devtools.gateway.openshift.kube.KubeConfigBuilder
17+
import kotlinx.coroutines.CoroutineScope
18+
import kotlinx.coroutines.cancel
19+
import kotlinx.coroutines.flow.MutableStateFlow
20+
import kotlinx.coroutines.launch
21+
import java.nio.file.Path
22+
import kotlin.io.path.exists
23+
import kotlin.io.path.isRegularFile
24+
25+
class KubeconfigMonitor(
26+
private val scope: CoroutineScope,
27+
private val kubeconfigFileWatcher: KubeconfigFileWatcher,
28+
private val kubeConfigBuilder: KubeConfigBuilder
29+
) {
30+
private val logger = thisLogger<KubeconfigMonitor>()
31+
32+
private val clusters = MutableStateFlow<List<Cluster>>(emptyList())
33+
34+
private val monitoredPaths = mutableSetOf<Path>()
35+
36+
/**
37+
* Collects cluster updates and executes the provided action for each update.
38+
* This is the primary way for UI components to observe cluster changes.
39+
*/
40+
suspend fun collectClusters(action: suspend (clusters: List<Cluster>) -> Unit) {
41+
clusters.collect(action)
42+
}
43+
44+
/**
45+
* Gets the current clusters state. Primarily for testing purposes.
46+
* Production code should use collectClusters() instead.
47+
*/
48+
internal fun getCurrentClusters(): List<Cluster> = clusters.value
49+
50+
fun startMonitoring() {
51+
scope.launch {
52+
kubeconfigFileWatcher.start()
53+
}
54+
updateMonitoredFiles()
55+
reparseAndPublishClusters()
56+
}
57+
58+
fun stopMonitoring() {
59+
kubeconfigFileWatcher.stop()
60+
scope.cancel()
61+
}
62+
63+
internal fun updateMonitoredFiles() {
64+
val newPaths = mutableSetOf<Path>()
65+
66+
val envPaths = kubeConfigBuilder.getKubeconfigEnvPaths()
67+
if (envPaths.isNotEmpty()) {
68+
newPaths.addAll(getValidPaths(envPaths))
69+
} else {
70+
newPaths.addAll(getDefaultPaths())
71+
}
72+
73+
watchNewPaths(newPaths)
74+
stopWatchingRemovedPaths(newPaths)
75+
76+
monitoredPaths.clear()
77+
monitoredPaths.addAll(newPaths)
78+
}
79+
80+
private fun getValidPaths(paths: List<Path>): List<Path> {
81+
return paths.partition { it.exists() && it.isRegularFile() }.let { (valid, invalid) ->
82+
invalid.forEach {
83+
logger.warn("KUBECONFIG specified file does not exist or is not a regular file: $it")
84+
}
85+
valid
86+
}
87+
}
88+
89+
private fun getDefaultPaths(): List<Path> {
90+
val defaultPaths =
91+
kubeConfigBuilder.getDefaultKubeconfigPath()
92+
if (defaultPaths.isEmpty()) {
93+
logger.debug("Default kubeconfig file does not exist.")
94+
}
95+
return defaultPaths
96+
}
97+
98+
private fun stopWatchingRemovedPaths(newPaths: MutableSet<Path>) {
99+
(monitoredPaths - newPaths).forEach { path ->
100+
kubeconfigFileWatcher.removeFile(path)
101+
logger.info("Stopped monitoring kubeconfig file: $path")
102+
}
103+
}
104+
105+
private fun watchNewPaths(newPaths: MutableSet<Path>) {
106+
(newPaths - monitoredPaths).forEach { path ->
107+
kubeconfigFileWatcher.addFile(path)
108+
logger.info("Started monitoring kubeconfig file: $path")
109+
}
110+
}
111+
112+
internal fun reparseAndPublishClusters() {
113+
val allClusters = kubeConfigBuilder.getClusters(monitoredPaths.toList())
114+
clusters.value = allClusters
115+
logger.info("Reparsed kubeconfig files. Found ${allClusters.size} clusters.")
116+
}
117+
118+
fun onFileChanged(filePath: Path) {
119+
logger.info("Kubeconfig file changed: $filePath. Reparsing and updating clusters.")
120+
reparseAndPublishClusters()
121+
}
122+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ class DevWorkspaces(private val client: ApiClient) {
186186
@Throws(ApiException::class)
187187
private fun doPatch(namespace: String, name: String, body: Any) {
188188
PatchUtils.patch(
189-
DevWorkspace.javaClass,
189+
DevWorkspace.Companion::class.java,
190190
{
191191
customApi.patchNamespacedCustomObject(
192192
"workspace.devfile.io",

src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,39 +12,52 @@
1212
package com.redhat.devtools.gateway.openshift
1313

1414
import com.intellij.openapi.diagnostic.thisLogger
15-
import com.redhat.devtools.gateway.openshift.kube.InvalidKubeConfigException
1615
import com.redhat.devtools.gateway.openshift.kube.KubeConfigBuilder
1716
import io.kubernetes.client.openapi.ApiClient
1817
import io.kubernetes.client.util.ClientBuilder
1918
import io.kubernetes.client.util.Config
2019
import io.kubernetes.client.util.KubeConfig
2120
import java.io.StringReader
2221

23-
class OpenShiftClientFactory() {
22+
class OpenShiftClientFactory(private val kubeConfigBuilder: KubeConfigBuilder) {
2423
private val userName = "openshift_user"
2524
private val contextName = "openshift_context"
2625
private val clusterName = "openshift_cluster"
26+
27+
private var lastUsedKubeConfig: KubeConfig? = null
2728

2829
fun create(): ApiClient {
29-
val envKubeConfig = System.getenv("KUBECONFIG")
30-
if (envKubeConfig != null) {
30+
val effectiveKubeConfig = kubeConfigBuilder.buildEffectiveKubeConfig()
31+
32+
return if (effectiveKubeConfig != null) {
3133
try {
32-
val effectiveConfigYaml = KubeConfigBuilder.fromEnvVar()
33-
val reader = StringReader(effectiveConfigYaml)
34+
val reader = StringReader(effectiveKubeConfig)
3435
val kubeConfig = KubeConfig.loadKubeConfig(reader)
35-
return ClientBuilder.kubeconfig(kubeConfig).build()
36-
} catch (err: InvalidKubeConfigException) {
37-
thisLogger().debug("Failed to build an effective Kube config from `KUBECONFIG` due to error: ${err.message}. Falling back to the default ApiClient.")
36+
lastUsedKubeConfig = kubeConfig
37+
ClientBuilder.kubeconfig(kubeConfig).build()
38+
} catch (e: Exception) {
39+
thisLogger().debug("Failed to build effective Kube config from discovered files due to error: ${e.message}. Falling back to the default ApiClient.")
40+
lastUsedKubeConfig = null
41+
ClientBuilder.defaultClient()
3842
}
43+
} else {
44+
thisLogger().debug("No effective kubeconfig found. Falling back to default ApiClient.")
45+
lastUsedKubeConfig = null
46+
ClientBuilder.defaultClient()
3947
}
40-
41-
return ClientBuilder.defaultClient()
4248
}
4349

4450
fun create(server: String, token: CharArray): ApiClient {
4551
val kubeConfig = createKubeConfig(server, token)
52+
lastUsedKubeConfig = kubeConfig
4653
return Config.fromConfig(kubeConfig)
4754
}
55+
56+
fun isTokenAuthUsed(): Boolean {
57+
return lastUsedKubeConfig?.let {
58+
KubeConfigBuilder.isTokenAuthUsed(it)
59+
} ?: false
60+
}
4861

4962
private fun createKubeConfig(server: String, token: CharArray): KubeConfig {
5063
val cluster = mapOf(
@@ -70,10 +83,9 @@ class OpenShiftClientFactory() {
7083
)
7184
)
7285

73-
7486
val kubeConfig = KubeConfig(arrayListOf(context), arrayListOf(cluster), arrayListOf(user))
7587
kubeConfig.setContext(contextName)
7688

7789
return kubeConfig
7890
}
79-
}
91+
}

0 commit comments

Comments
 (0)