Skip to content

Commit c6f7184

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

File tree

17 files changed

+1798
-279
lines changed

17 files changed

+1798
-279
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+
this.clientFactory = factory
189+
ctx.client = factory.create()
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?.isTokenAuth() == 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.newKeySet<Path>()
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.contains(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.contains(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.add(path)
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: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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.MutableSharedFlow
20+
import kotlinx.coroutines.flow.asSharedFlow
21+
import kotlinx.coroutines.launch
22+
import java.nio.file.Path
23+
24+
class KubeconfigMonitor(
25+
private val scope: CoroutineScope,
26+
private val fileWatcher: KubeconfigFileWatcher,
27+
private val kubeConfigUtils: KubeConfigUtils
28+
) {
29+
private val logger = thisLogger<KubeconfigMonitor>()
30+
31+
private val _clusters = MutableSharedFlow<List<Cluster>>(replay = 1)
32+
private val clusters = _clusters.asSharedFlow()
33+
34+
private val monitoredPaths = mutableSetOf<Path>()
35+
36+
/**
37+
* Runs the given action for each collected cluster.
38+
*/
39+
suspend fun onClusterCollected(action: suspend (clusters: List<Cluster>) -> Unit) {
40+
logger.info("Setting up SharedFlow collection for cluster updates")
41+
clusters.collect { clusters ->
42+
logger.info("Found ${clusters.size} clusters")
43+
action(clusters)
44+
}
45+
}
46+
47+
/**
48+
* Returns the current clusters. For testing purposes only.
49+
*
50+
* @see [onClusterCollected]
51+
*/
52+
internal fun getCurrentClusters(): List<Cluster> = _clusters.replayCache.firstOrNull() ?: emptyList()
53+
54+
fun start() {
55+
fileWatcher.onFileChanged(::onFileChanged)
56+
scope.launch {
57+
fileWatcher.start()
58+
}
59+
updateMonitoredPaths()
60+
refreshClusters()
61+
}
62+
63+
fun stop() {
64+
fileWatcher.stop()
65+
fileWatcher.onFileChanged(null)
66+
scope.cancel()
67+
}
68+
69+
internal fun updateMonitoredPaths() {
70+
val newPaths = mutableSetOf<Path>()
71+
newPaths.addAll(kubeConfigUtils.getAllConfigs())
72+
startWatchingNew(newPaths)
73+
stopWatchingRemoved(newPaths)
74+
75+
monitoredPaths.clear()
76+
monitoredPaths.addAll(newPaths)
77+
logger.info("Monitored paths: $monitoredPaths")
78+
}
79+
80+
private fun stopWatchingRemoved(newPaths: Set<Path>) {
81+
(monitoredPaths - newPaths).forEach { path ->
82+
fileWatcher.removeFile(path)
83+
logger.info("Stopped monitoring kubeconfig file: $path")
84+
}
85+
}
86+
87+
private fun startWatchingNew(newPaths: Set<Path>) {
88+
(newPaths - monitoredPaths).forEach { path ->
89+
fileWatcher.addFile(path)
90+
logger.info("Started monitoring kubeconfig file: $path")
91+
}
92+
}
93+
94+
internal fun refreshClusters() {
95+
logger.info("Reparsing kubeconfig files. Monitored paths: $monitoredPaths")
96+
val allClusters = kubeConfigUtils.getClusters(monitoredPaths.toList())
97+
scope.launch {
98+
logger.info("Emitting ${allClusters.size} clusters to SharedFlow")
99+
_clusters.emit(allClusters)
100+
}
101+
logger.info("Reparsed kubeconfig files. Found ${allClusters.size} clusters: ${allClusters.map { "${it.name}@${it.url}" }}")
102+
}
103+
104+
fun onFileChanged(filePath: Path) {
105+
logger.info("Kubeconfig file changed: $filePath. Reparsing and updating clusters.")
106+
refreshClusters()
107+
}
108+
}

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::class.java,
190190
{
191191
customApi.patchNamespacedCustomObject(
192192
"workspace.devfile.io",

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

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,39 +12,50 @@
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) {
31-
try {
32-
val effectiveConfigYaml = KubeConfigBuilder.fromEnvVar()
33-
val reader = StringReader(effectiveConfigYaml)
34-
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.")
30+
val mergedConfig = kubeConfigBuilder.getAllConfigsMerged()
31+
?: run {
32+
thisLogger().debug("No effective kubeconfig found. Falling back to default ApiClient.")
33+
lastUsedKubeConfig = null
34+
return ClientBuilder.defaultClient()
3835
}
39-
}
4036

41-
return ClientBuilder.defaultClient()
37+
return try {
38+
val kubeConfig = KubeConfig.loadKubeConfig(StringReader(mergedConfig))
39+
lastUsedKubeConfig = kubeConfig
40+
ClientBuilder.kubeconfig(kubeConfig).build()
41+
} catch (e: Exception) {
42+
thisLogger().debug("Failed to build effective Kube config from discovered files due to error: ${e.message}. Falling back to the default ApiClient.")
43+
lastUsedKubeConfig = null
44+
ClientBuilder.defaultClient()
45+
}
4246
}
4347

4448
fun create(server: String, token: CharArray): ApiClient {
4549
val kubeConfig = createKubeConfig(server, token)
50+
lastUsedKubeConfig = kubeConfig
4651
return Config.fromConfig(kubeConfig)
4752
}
53+
54+
fun isTokenAuth(): Boolean {
55+
return lastUsedKubeConfig?.let {
56+
KubeConfigUtils.isTokenAuth(it)
57+
} ?: false
58+
}
4859

4960
private fun createKubeConfig(server: String, token: CharArray): KubeConfig {
5061
val cluster = mapOf(
@@ -70,7 +81,6 @@ class OpenShiftClientFactory() {
7081
)
7182
)
7283

73-
7484
val kubeConfig = KubeConfig(arrayListOf(context), arrayListOf(cluster), arrayListOf(user))
7585
kubeConfig.setContext(contextName)
7686

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

Lines changed: 6 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -12,48 +12,13 @@
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+
val url: String,
18+
val token: String?
1719
) {
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-
}
20+
override fun toString(): String {
21+
return "$name ($url)"
5822
}
23+
5924
}

0 commit comments

Comments
 (0)