Skip to content

Commit 73af277

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 <adietish@redhat.com>
1 parent c26a281 commit 73af277

File tree

15 files changed

+890
-248
lines changed

15 files changed

+890
-248
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 & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,14 @@ import com.jetbrains.gateway.api.GatewayConnectionHandle
2222
import com.jetbrains.gateway.api.GatewayConnectionProvider
2323
import com.redhat.devtools.gateway.openshift.DevWorkspaces
2424
import com.redhat.devtools.gateway.openshift.OpenShiftClientFactory
25-
import com.redhat.devtools.gateway.openshift.kube.KubeConfigBuilder
25+
import com.redhat.devtools.gateway.openshift.kube.KubeConfigUtils
2626
import com.redhat.devtools.gateway.openshift.kube.isNotFound
2727
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
3131
import kotlinx.coroutines.CompletableDeferred
3232
import kotlinx.coroutines.ExperimentalCoroutinesApi
33-
import kotlinx.coroutines.runBlocking
3433
import kotlinx.coroutines.suspendCancellableCoroutine
3534
import javax.swing.JComponent
3635
import javax.swing.Timer
@@ -46,6 +45,8 @@ private const val DW_NAME = "dwName"
4645
*/
4746
class DevSpacesConnectionProvider : GatewayConnectionProvider {
4847

48+
private var clientFactory: OpenShiftClientFactory? = null
49+
4950
@OptIn(ExperimentalCoroutinesApi::class)
5051
@Suppress("UnstableApiUsage")
5152
override suspend fun connect(
@@ -183,7 +184,9 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider {
183184
val ctx = DevSpacesContext()
184185

185186
indicator.text2 = "Initializing Kubernetes connection…"
186-
ctx.client = OpenShiftClientFactory().create()
187+
val factory = OpenShiftClientFactory(KubeConfigUtils)
188+
ctx.client = factory.create()
189+
clientFactory = factory
187190

188191
indicator.text2 = "Fetching DevWorkspace “$dwName” from namespace “$dwNamespace”…"
189192
ctx.devWorkspace = DevWorkspaces(ctx.client).get(dwNamespace, dwName)
@@ -225,7 +228,7 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider {
225228
private fun handleUnauthorizedError(err: ApiException): Boolean {
226229
if (!err.isUnauthorized()) return false
227230

228-
val tokenNote = if (KubeConfigBuilder.isTokenAuthUsed())
231+
val tokenNote = if (clientFactory?.isTokenAuthUsed() == true)
229232
"\n\nYou are using token-based authentication.\nUpdate your token in the kubeconfig file."
230233
else ""
231234

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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 = CoroutineScope(SupervisorJob() + Dispatchers.Default),
22+
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
23+
private val watchService: WatchService = FileSystems.getDefault().newWatchService()
24+
) {
25+
private var onFileChanged: ((Path) -> Unit)? = null
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(dispatcher) {
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?.invoke(changedFile)
47+
}
48+
}
49+
key.reset()
50+
}
51+
}
52+
}
53+
54+
fun stop() {
55+
watchJob?.cancel()
56+
watchJob = null
57+
watchService.close()
58+
}
59+
60+
fun addFile(path: Path) {
61+
if (!path.exists()
62+
|| !path.isRegularFile()) {
63+
return
64+
}
65+
val parentDir = path.parent
66+
if (parentDir != null
67+
&& !monitoredFiles.containsKey(path)) {
68+
val watchKey = parentDir.register(watchService,
69+
StandardWatchEventKinds.ENTRY_CREATE,
70+
StandardWatchEventKinds.ENTRY_MODIFY,
71+
StandardWatchEventKinds.ENTRY_DELETE
72+
)
73+
registeredKeys[watchKey] = parentDir
74+
monitoredFiles[path] = Unit
75+
onFileChanged?.invoke(path)
76+
}
77+
}
78+
79+
fun removeFile(path: Path) {
80+
monitoredFiles.remove(path)
81+
}
82+
83+
fun onFileChanged(action: ((Path) -> Unit)?) {
84+
this.onFileChanged = action
85+
}
86+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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.KubeConfigUtils
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+
23+
class KubeconfigMonitor(
24+
private val scope: CoroutineScope,
25+
private val fileWatcher: KubeconfigFileWatcher,
26+
private val kubeConfigBuilder: KubeConfigUtils
27+
) {
28+
private val logger = thisLogger<KubeconfigMonitor>()
29+
30+
private val clusters = MutableStateFlow<List<Cluster>>(emptyList())
31+
32+
private val monitoredPaths = mutableSetOf<Path>()
33+
34+
/**
35+
* Runs the given action for each collected cluster.
36+
*/
37+
suspend fun onClusterCollected(action: suspend (clusters: List<Cluster>) -> Unit) {
38+
clusters.collect(action)
39+
}
40+
41+
/**
42+
* Returns the current clusters. For testing purposes only.
43+
*
44+
* @see [onClusterCollected]
45+
*/
46+
internal fun getCurrentClusters(): List<Cluster> = clusters.value
47+
48+
fun start() {
49+
fileWatcher.onFileChanged(::onFileChanged)
50+
scope.launch {
51+
fileWatcher.start()
52+
}
53+
updateMonitoredPaths()
54+
refreshClusters()
55+
}
56+
57+
fun stop() {
58+
fileWatcher.stop()
59+
fileWatcher.onFileChanged(null)
60+
scope.cancel()
61+
}
62+
63+
internal fun updateMonitoredPaths() {
64+
val newPaths = mutableSetOf<Path>()
65+
newPaths.addAll(kubeConfigBuilder.getAllConfigs())
66+
watchNewPaths(newPaths)
67+
stopWatchingRemovedPaths(newPaths)
68+
69+
monitoredPaths.clear()
70+
monitoredPaths.addAll(newPaths)
71+
logger.info("Currently monitoring paths: $monitoredPaths")
72+
}
73+
74+
private fun stopWatchingRemovedPaths(newPaths: MutableSet<Path>) {
75+
(monitoredPaths - newPaths).forEach { path ->
76+
fileWatcher.removeFile(path)
77+
logger.info("Stopped monitoring kubeconfig file: $path")
78+
}
79+
}
80+
81+
private fun watchNewPaths(newPaths: MutableSet<Path>) {
82+
(newPaths - monitoredPaths).forEach { path ->
83+
fileWatcher.addFile(path)
84+
logger.info("Started monitoring kubeconfig file: $path")
85+
}
86+
}
87+
88+
internal fun refreshClusters() {
89+
logger.info("Reparsing kubeconfig files. Monitored paths: $monitoredPaths")
90+
val allClusters = kubeConfigBuilder.getClusters(monitoredPaths.toList())
91+
clusters.value = allClusters
92+
logger.info("Reparsed kubeconfig files. Found ${allClusters.size} clusters: ${allClusters.map { "${it.name}@${it.apiServerUrl}" }}")
93+
}
94+
95+
fun onFileChanged(filePath: Path) {
96+
logger.info("Kubeconfig file changed: $filePath. Reparsing and updating clusters.")
97+
refreshClusters()
98+
}
99+
}

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: 26 additions & 14 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
16-
import com.redhat.devtools.gateway.openshift.kube.KubeConfigBuilder
15+
import com.redhat.devtools.gateway.openshift.kube.KubeConfigUtils
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: KubeConfigUtils) {
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.getMergedConfig()
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+
KubeConfigUtils.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+
}

src/main/kotlin/com/redhat/devtools/gateway/openshift/kube/Cluster.kt

Lines changed: 5 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -12,48 +12,9 @@
1212
package com.redhat.devtools.gateway.openshift.kube
1313

1414
data class Cluster(
15+
val id: String,
1516
val name: String,
16-
val url: String
17-
) {
18-
companion object {
19-
private val SCHEMA_REGEX = Regex("^https?://")
20-
private val PATH_REGEX = Regex("/.*$")
21-
22-
fun fromString(string: String): Cluster? {
23-
return if (isUrl(string)) {
24-
fromUrl(string)
25-
} else {
26-
val match = getUrlAndNameMatch(string)
27-
if (match != null) {
28-
val (name, url) = match.destructured
29-
Cluster(name, url)
30-
} else {
31-
null
32-
}
33-
}
34-
}
35-
36-
fun toString(cluster: Cluster?): String {
37-
return if (cluster == null) {
38-
""
39-
} else {
40-
"${cluster.name} (${cluster.url})"
41-
}
42-
}
43-
44-
private fun isUrl(text: String): Boolean {
45-
return text.startsWith("https://") || text.startsWith("http://")
46-
}
47-
48-
private fun fromUrl(url: String): Cluster {
49-
val name = url
50-
.replace(SCHEMA_REGEX, "")
51-
.replace(PATH_REGEX, "")
52-
return Cluster(name, url)
53-
}
54-
55-
private fun getUrlAndNameMatch(text: String): MatchResult? {
56-
return Regex("""^(.+)\s*\((https?://.+)\)$""").find(text)
57-
}
58-
}
59-
}
17+
val apiServerUrl: String,
18+
val caData: String?,
19+
val token: String?
20+
)

0 commit comments

Comments
 (0)