Skip to content

Commit b5007c2

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 f23ccff commit b5007c2

File tree

19 files changed

+1768
-301
lines changed

19 files changed

+1768
-301
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.1")
3637
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.0.1")
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: 9 additions & 6 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
26-
import com.redhat.devtools.gateway.openshift.kube.isNotFound
27-
import com.redhat.devtools.gateway.openshift.kube.isUnauthorized
25+
import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils
26+
import com.redhat.devtools.gateway.openshift.isNotFound
27+
import com.redhat.devtools.gateway.openshift.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: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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 FileWatcher(
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+
this.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+
pollEvents(key, dir)
40+
key.reset()
41+
}
42+
}
43+
}
44+
45+
private fun pollEvents(key: WatchKey, dir: Path) {
46+
for (event in key.pollEvents()) {
47+
val relativePath = event.context() as? Path ?: continue
48+
val changedFile = dir.resolve(relativePath)
49+
50+
if (monitoredFiles.contains(changedFile)
51+
&& event.kind() != StandardWatchEventKinds.OVERFLOW
52+
) {
53+
onFileChanged?.invoke(changedFile)
54+
}
55+
}
56+
}
57+
58+
fun stop() {
59+
watchJob?.cancel()
60+
watchJob = null
61+
watchService.close()
62+
}
63+
64+
fun addFile(path: Path) {
65+
if (!path.exists()
66+
|| !path.isRegularFile()) {
67+
return
68+
}
69+
val parentDir = path.parent
70+
if (parentDir != null
71+
&& !monitoredFiles.contains(path)) {
72+
val watchKey = parentDir.register(watchService,
73+
StandardWatchEventKinds.ENTRY_CREATE,
74+
StandardWatchEventKinds.ENTRY_MODIFY,
75+
StandardWatchEventKinds.ENTRY_DELETE
76+
)
77+
registeredKeys[watchKey] = parentDir
78+
monitoredFiles.add(path)
79+
onFileChanged?.invoke(path)
80+
}
81+
}
82+
83+
fun removeFile(path: Path) {
84+
monitoredFiles.remove(path)
85+
}
86+
87+
fun onFileChanged(action: ((Path) -> Unit)?) {
88+
this.onFileChanged = action
89+
}
90+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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 io.kubernetes.client.util.KubeConfig
15+
import kotlin.collections.get
16+
17+
/**
18+
* Domain classes representing the structure of a kubeconfig file.
19+
*/
20+
data class KubeConfigNamedCluster(
21+
val name: String,
22+
val cluster: KubeConfigCluster
23+
) {
24+
companion object {
25+
fun fromMap(name: String, clusterObject: Any?): KubeConfigNamedCluster? {
26+
val clusterMap = clusterObject as? Map<*, *> ?: return null
27+
val clusterDetails = clusterMap["cluster"] as? Map<*, *> ?: return null
28+
29+
return KubeConfigNamedCluster(
30+
name = name,
31+
cluster = KubeConfigCluster.fromMap(clusterDetails) ?: return null
32+
)
33+
}
34+
35+
fun fromKubeConfig(kubeConfig: KubeConfig): List<KubeConfigNamedCluster> {
36+
return (kubeConfig.clusters as? List<*>)
37+
?.mapNotNull { clusterObject ->
38+
val clusterMap = clusterObject as? Map<*, *> ?: return@mapNotNull null
39+
val name = clusterMap["name"] as? String ?: return@mapNotNull null
40+
fromMap(name, clusterObject)
41+
} ?: emptyList()
42+
}
43+
}
44+
}
45+
46+
data class KubeConfigCluster(
47+
val server: String,
48+
val certificateAuthorityData: String? = null,
49+
val insecureSkipTlsVerify: Boolean? = null
50+
) {
51+
companion object {
52+
fun fromMap(map: Map<*, *>): KubeConfigCluster? {
53+
val server = map["server"] as? String ?: return null
54+
return KubeConfigCluster(
55+
server = server,
56+
certificateAuthorityData = map["certificate-authority-data"] as? String,
57+
insecureSkipTlsVerify = map["insecure-skip-tls-verify"] as? Boolean
58+
)
59+
}
60+
}
61+
}
62+
63+
data class KubeConfigNamedContext(
64+
val name: String,
65+
val context: KubeConfigContext
66+
) {
67+
companion object {
68+
fun getByClusterName(clusterName: String, kubeConfig: KubeConfig): KubeConfigNamedContext? {
69+
return (kubeConfig.contexts as? List<*>)?.firstNotNullOfOrNull { contextObject ->
70+
val contextMap = contextObject as? Map<*, *> ?: return@firstNotNullOfOrNull null
71+
val contextName = contextMap["name"] as? String ?: return@firstNotNullOfOrNull null
72+
val contextEntry = getByName(contextName, contextObject)
73+
if (contextEntry?.context?.cluster == clusterName) {
74+
contextEntry
75+
} else {
76+
null
77+
}
78+
}
79+
}
80+
81+
private fun getByName(name: String, contextObject: Any?): KubeConfigNamedContext? {
82+
val contextMap = contextObject as? Map<*, *> ?: return null
83+
val contextDetails = contextMap["context"] as? Map<*, *> ?: return null
84+
85+
return KubeConfigNamedContext(
86+
name = name,
87+
context = KubeConfigContext.fromMap(contextDetails) ?: return null
88+
)
89+
}
90+
}
91+
}
92+
93+
data class KubeConfigContext(
94+
val cluster: String,
95+
val user: String,
96+
val namespace: String? = null
97+
) {
98+
companion object {
99+
fun fromMap(map: Map<*, *>): KubeConfigContext? {
100+
val cluster = map["cluster"] as? String ?: return null
101+
val user = map["user"] as? String ?: return null
102+
103+
return KubeConfigContext(
104+
cluster = cluster,
105+
user = user,
106+
namespace = map["namespace"] as? String
107+
)
108+
}
109+
}
110+
}
111+
112+
data class KubeConfigNamedUser(
113+
val name: String,
114+
val user: KubeConfigUser
115+
) {
116+
companion object {
117+
fun fromMap(name: String, userObject: Any?): KubeConfigNamedUser? {
118+
val userMap = userObject as? Map<*, *> ?: return null
119+
val userDetails = userMap["user"] as? Map<*, *> ?: return null
120+
121+
return KubeConfigNamedUser(
122+
name = name,
123+
user = KubeConfigUser.fromMap(userDetails)
124+
)
125+
}
126+
127+
fun getUserTokenForCluster(clusterName: String, kubeConfig: KubeConfig): String? {
128+
val contextEntry = KubeConfigNamedContext.getByClusterName(clusterName, kubeConfig) ?: return null
129+
val userObject = (kubeConfig.users as? List<*>)?.firstOrNull { userObject ->
130+
val userMap = userObject as? Map<*, *> ?: return@firstOrNull false
131+
val userName = userMap["name"] as? String ?: return@firstOrNull false
132+
userName == contextEntry.context.user
133+
} ?: return null
134+
return fromMap(contextEntry.context.user, userObject)?.user?.token
135+
}
136+
137+
fun isTokenAuth(kubeConfig: KubeConfig): Boolean {
138+
return kubeConfig.credentials?.containsKey(KubeConfig.CRED_TOKEN_KEY) == true
139+
}
140+
}
141+
}
142+
143+
data class KubeConfigUser(
144+
val token: String? = null,
145+
val clientCertificateData: String? = null,
146+
val clientKeyData: String? = null,
147+
val username: String? = null,
148+
val password: String? = null
149+
) {
150+
companion object {
151+
fun fromMap(map: Map<*, *>): KubeConfigUser {
152+
return KubeConfigUser(
153+
token = map["token"] as? String,
154+
clientCertificateData = map["client-certificate-data"] as? String,
155+
clientKeyData = map["client-key-data"] as? String,
156+
username = map["username"] as? String,
157+
password = map["password"] as? String
158+
)
159+
}
160+
}
161+
}
162+

0 commit comments

Comments
 (0)