diff --git a/build.gradle.kts b/build.gradle.kts index 2431c7c3..2f3f6002 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,4 @@ +import org.gradle.kotlin.dsl.dependencies import org.jetbrains.changelog.Changelog import org.jetbrains.changelog.markdownToHTML import org.jetbrains.intellij.platform.gradle.TestFrameworkType @@ -35,7 +36,12 @@ dependencies { testImplementation("org.junit.platform:junit-platform-launcher:6.0.1") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.0.1") testImplementation("org.assertj:assertj-core:3.27.6") + testImplementation("io.mockk:mockk:1.14.6") + testImplementation("io.mockk:mockk-agent-jvm:1.14.6") + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0") // IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html intellijPlatform { diff --git a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt index 4d0392ed..b896efee 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt @@ -22,15 +22,14 @@ import com.jetbrains.gateway.api.GatewayConnectionHandle import com.jetbrains.gateway.api.GatewayConnectionProvider import com.redhat.devtools.gateway.openshift.DevWorkspaces import com.redhat.devtools.gateway.openshift.OpenShiftClientFactory -import com.redhat.devtools.gateway.openshift.kube.KubeConfigBuilder -import com.redhat.devtools.gateway.openshift.kube.isNotFound -import com.redhat.devtools.gateway.openshift.kube.isUnauthorized +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils +import com.redhat.devtools.gateway.openshift.isNotFound +import com.redhat.devtools.gateway.openshift.isUnauthorized import com.redhat.devtools.gateway.util.messageWithoutPrefix import com.redhat.devtools.gateway.view.ui.Dialogs import io.kubernetes.client.openapi.ApiException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine import javax.swing.JComponent import javax.swing.Timer @@ -46,6 +45,8 @@ private const val DW_NAME = "dwName" */ class DevSpacesConnectionProvider : GatewayConnectionProvider { + private var clientFactory: OpenShiftClientFactory? = null + @OptIn(ExperimentalCoroutinesApi::class) @Suppress("UnstableApiUsage") override suspend fun connect( @@ -183,7 +184,9 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider { val ctx = DevSpacesContext() indicator.text2 = "Initializing Kubernetes connection…" - ctx.client = OpenShiftClientFactory().create() + val factory = OpenShiftClientFactory(KubeConfigUtils) + this.clientFactory = factory + ctx.client = factory.create() indicator.text2 = "Fetching DevWorkspace “$dwName” from namespace “$dwNamespace”…" ctx.devWorkspace = DevWorkspaces(ctx.client).get(dwNamespace, dwName) @@ -225,7 +228,7 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider { private fun handleUnauthorizedError(err: ApiException): Boolean { if (!err.isUnauthorized()) return false - val tokenNote = if (KubeConfigBuilder.isTokenAuthUsed()) + val tokenNote = if (clientFactory?.isTokenAuth() == true) "\n\nYou are using token-based authentication.\nUpdate your token in the kubeconfig file." else "" diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcher.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcher.kt new file mode 100644 index 00000000..41c835b2 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcher.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.kubeconfig + +import kotlinx.coroutines.* +import java.nio.file.* +import java.util.concurrent.ConcurrentHashMap +import kotlin.io.path.exists +import kotlin.io.path.isRegularFile + +class FileWatcher( + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val watchService: WatchService = FileSystems.getDefault().newWatchService() +) { + private var onFileChanged: ((Path) -> Unit)? = null + private val registeredKeys = ConcurrentHashMap() + private val monitoredFiles = ConcurrentHashMap.newKeySet() + private var watchJob: Job? = null + + fun start() { + this.watchJob = scope.launch(dispatcher) { + while (isActive) { + val key = watchService.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS) + if (key == null) { + delay(100) + continue + } + val dir = registeredKeys[key] ?: continue + pollEvents(key, dir) + key.reset() + } + } + } + + fun stop() { + watchJob?.cancel() + watchJob = null + watchService.close() + } + + fun addFile(path: Path): FileWatcher { + if (!path.exists() + || !path.isRegularFile()) { + return this + } + val parentDir = path.parent + if (parentDir != null + && !monitoredFiles.contains(path)) { + val watchKey = parentDir.register(watchService, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_MODIFY, + StandardWatchEventKinds.ENTRY_DELETE + ) + registeredKeys[watchKey] = parentDir + monitoredFiles.add(path) + onFileChanged?.invoke(path) + } + return this + } + + fun removeFile(path: Path): FileWatcher { + monitoredFiles.remove(path) + return this + } + + fun onFileChanged(action: ((Path) -> Unit)?) { + this.onFileChanged = action + } + + private fun pollEvents(key: WatchKey, dir: Path) { + for (event in key.pollEvents()) { + val relativePath = event.context() as? Path ?: continue + val changedFile = dir.resolve(relativePath) + + if (monitoredFiles.contains(changedFile) + && event.kind() != StandardWatchEventKinds.OVERFLOW + ) { + onFileChanged?.invoke(changedFile) + } + } + } + +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt new file mode 100644 index 00000000..3fd0c68a --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.kubeconfig + +import io.kubernetes.client.util.KubeConfig +import kotlin.collections.get + +/** + * Domain classes representing the structure of a kubeconfig file. + */ +data class KubeConfigNamedCluster( + val name: String, + val cluster: KubeConfigCluster +) { + companion object { + fun fromMap(name: String, clusterObject: Any?): KubeConfigNamedCluster? { + val clusterMap = clusterObject as? Map<*, *> ?: return null + val clusterDetails = clusterMap["cluster"] as? Map<*, *> ?: return null + + return KubeConfigNamedCluster( + name = name, + cluster = KubeConfigCluster.fromMap(clusterDetails) ?: return null + ) + } + + fun fromKubeConfig(kubeConfig: KubeConfig): List { + return (kubeConfig.clusters as? List<*>) + ?.mapNotNull { clusterObject -> + val clusterMap = clusterObject as? Map<*, *> ?: return@mapNotNull null + val name = clusterMap["name"] as? String ?: return@mapNotNull null + fromMap(name, clusterObject) + } ?: emptyList() + } + } +} + +data class KubeConfigCluster( + val server: String, + val certificateAuthorityData: String? = null, + val insecureSkipTlsVerify: Boolean? = null +) { + companion object { + fun fromMap(map: Map<*, *>): KubeConfigCluster? { + val server = map["server"] as? String ?: return null + return KubeConfigCluster( + server = server, + certificateAuthorityData = map["certificate-authority-data"] as? String, + insecureSkipTlsVerify = map["insecure-skip-tls-verify"] as? Boolean + ) + } + } +} + +data class KubeConfigNamedContext( + val name: String, + val context: KubeConfigContext +) { + companion object { + fun getByClusterName(clusterName: String, kubeConfig: KubeConfig): KubeConfigNamedContext? { + return (kubeConfig.contexts as? List<*>)?.firstNotNullOfOrNull { contextObject -> + val contextMap = contextObject as? Map<*, *> ?: return@firstNotNullOfOrNull null + val contextName = contextMap["name"] as? String ?: return@firstNotNullOfOrNull null + val contextEntry = getByName(contextName, contextObject) + if (contextEntry?.context?.cluster == clusterName) { + contextEntry + } else { + null + } + } + } + + private fun getByName(name: String, contextObject: Any?): KubeConfigNamedContext? { + val contextMap = contextObject as? Map<*, *> ?: return null + val contextDetails = contextMap["context"] as? Map<*, *> ?: return null + + return KubeConfigNamedContext( + name = name, + context = KubeConfigContext.fromMap(contextDetails) ?: return null + ) + } + } +} + +data class KubeConfigContext( + val cluster: String, + val user: String, + val namespace: String? = null +) { + companion object { + fun fromMap(map: Map<*, *>): KubeConfigContext? { + val cluster = map["cluster"] as? String ?: return null + val user = map["user"] as? String ?: return null + + return KubeConfigContext( + cluster = cluster, + user = user, + namespace = map["namespace"] as? String + ) + } + } +} + +data class KubeConfigNamedUser( + val name: String, + val user: KubeConfigUser +) { + companion object { + fun fromMap(name: String, userObject: Any?): KubeConfigNamedUser? { + val userMap = userObject as? Map<*, *> ?: return null + val userDetails = userMap["user"] as? Map<*, *> ?: return null + + return KubeConfigNamedUser( + name = name, + user = KubeConfigUser.fromMap(userDetails) + ) + } + + fun getUserTokenForCluster(clusterName: String, kubeConfig: KubeConfig): String? { + val contextEntry = KubeConfigNamedContext.getByClusterName(clusterName, kubeConfig) ?: return null + val userObject = (kubeConfig.users as? List<*>)?.firstOrNull { userObject -> + val userMap = userObject as? Map<*, *> ?: return@firstOrNull false + val userName = userMap["name"] as? String ?: return@firstOrNull false + userName == contextEntry.context.user + } ?: return null + return fromMap(contextEntry.context.user, userObject)?.user?.token + } + + fun isTokenAuth(kubeConfig: KubeConfig): Boolean { + return kubeConfig.credentials?.containsKey(KubeConfig.CRED_TOKEN_KEY) == true + } + } +} + +data class KubeConfigUser( + val token: String? = null, + val clientCertificateData: String? = null, + val clientKeyData: String? = null, + val username: String? = null, + val password: String? = null +) { + companion object { + fun fromMap(map: Map<*, *>): KubeConfigUser { + return KubeConfigUser( + token = map["token"] as? String, + clientCertificateData = map["client-certificate-data"] as? String, + clientKeyData = map["client-key-data"] as? String, + username = map["username"] as? String, + password = map["password"] as? String + ) + } + } +} + diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt new file mode 100644 index 00000000..40ac920f --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt @@ -0,0 +1,139 @@ +package com.redhat.devtools.gateway.kubeconfig + +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.util.EnvironmentUtil +import com.redhat.devtools.gateway.openshift.Cluster +import io.kubernetes.client.util.KubeConfig +import java.io.File +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.exists +import kotlin.io.path.isRegularFile + +object KubeConfigUtils { + + private val logger = thisLogger() + + fun isCurrentUserTokenAuth(kubeConfig: KubeConfig): Boolean { + return kubeConfig.credentials.containsKey(KubeConfig.CRED_TOKEN_KEY) + } + + fun getClusters(kubeconfigPaths: List): List { + logger.info("Getting clusters from kubeconfig paths: $kubeconfigPaths") + val kubeConfigs = toKubeConfigs(kubeconfigPaths) + logger.info("Loaded ${kubeConfigs.size} kubeconfig files") + val clusters = kubeConfigs + .flatMap { kubeConfig -> + val clusters = KubeConfigNamedCluster.fromKubeConfig(kubeConfig) + clusters.map { clusterEntry -> + val cluster = toCluster(clusterEntry, kubeConfig) + logger.info("Parsed cluster: ${cluster.name} at ${cluster.url}") + cluster + } + } + .distinctBy { it.id } + + logger.info("Found ${clusters.size} distinct clusters") + return clusters + } + + private fun toKubeConfigs(kubeconfigPaths: List): List = kubeconfigPaths + .filter { path -> + val valid = isValid(path) + if (!valid) { + logger.info("Kubeconfig file does not exist or is not a regular file: $path") + } + valid + }.mapNotNull { path -> + try { + val kubeConfig = KubeConfig.loadKubeConfig(path.toFile().bufferedReader()) + logger.info("loaded kubeconfig from: $path") + kubeConfig + } catch (e: Exception) { + logger.warn("Error parsing kubeconfig file '$path': ${e.message}", e) + null + } + } + + private fun toCluster(clusterEntry: KubeConfigNamedCluster, kubeConfig: KubeConfig): Cluster { + val userToken = KubeConfigNamedUser.getUserTokenForCluster(clusterEntry.name, kubeConfig) + + return Cluster( + id = generateClusterId(clusterEntry.name, clusterEntry.cluster.server), + name = clusterEntry.name, + url = clusterEntry.cluster.server, + token = userToken + ) + } + + private fun generateClusterId(clusterName: String, apiServerUrl: String): String { + return "$clusterName@${ + apiServerUrl + .removePrefix("https://") + .removePrefix("http://") + }" + } + + private fun getEnvConfigs(): List { + val env = System.getenv("KUBECONFIG") + ?: EnvironmentUtil.getValue("KUBECONFIG") + ?: return emptyList() + return env + .split(File.pathSeparator) + .map(String::trim) + .filter(String::isNotEmpty) + .map { Path(it) } + } + + private fun getDefaultConfigs(): List { + return listOfNotNull( + Path(System.getProperty("user.home"), ".kube", "config") + .takeIf { isValid(it) } + ) + } + + fun getAllConfigs(): List { + val envPaths = getEnvConfigs() + return if (envPaths.isNotEmpty()) { + envPaths.filter { isValid(it) } + } else { + getDefaultConfigs() + } + } + + private fun isValid(paths: Path): Boolean { + return paths.exists() + && paths.isRegularFile() + } + + fun getAllConfigsMerged(): String? { + val kubeConfigPaths = getAllConfigs() + + if (kubeConfigPaths.isEmpty()) { + logger.debug("No kubeconfig files found.") + return null + } + + val mergedKubeConfigs = mergeConfigs(kubeConfigPaths) + if (mergedKubeConfigs.isEmpty()) { + logger.debug("No valid kubeconfig content found.") + return null + } + + return mergedKubeConfigs + } + + private fun mergeConfigs(kubeconfigs: List): String { + return kubeconfigs + .mapNotNull { path -> + try { + path.toFile().readText() + } catch (e: Exception) { + logger.warn("Failed to read kubeconfig file '$path': ${e.message}") + null + } + } + .joinToString("\n---\n") + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeconfigMonitor.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeconfigMonitor.kt new file mode 100644 index 00000000..34c82665 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeconfigMonitor.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.kubeconfig + +import com.intellij.openapi.diagnostic.thisLogger +import com.redhat.devtools.gateway.openshift.Cluster +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import java.nio.file.Path + +class KubeconfigMonitor( + private val scope: CoroutineScope, + private val fileWatcher: FileWatcher, + private val kubeConfigUtils: KubeConfigUtils +) { + private val logger = thisLogger() + + private val _clusters = MutableSharedFlow>(replay = 1) + private val clusters = _clusters.asSharedFlow() + + private val monitoredPaths = mutableSetOf() + + /** + * Runs the given action for each collected cluster. + */ + suspend fun onClustersCollected(action: suspend (clusters: List) -> Unit) { + logger.info("Setting up SharedFlow collection for cluster updates") + clusters.collect { clusters -> + logger.info("Found ${clusters.size} clusters") + action(clusters) + } + } + + /** + * Returns the current clusters. For testing purposes only. + * + * @see [onClustersCollected] + */ + internal fun getCurrentClusters(): List = _clusters.replayCache.firstOrNull() ?: emptyList() + + fun start() { + fileWatcher.onFileChanged(::onFileChanged) + scope.launch { + fileWatcher.start() + } + updateMonitoredPaths() + refreshClusters() + } + + fun stop() { + fileWatcher.stop() + fileWatcher.onFileChanged(null) + scope.cancel() + } + + internal fun updateMonitoredPaths() { + val newPaths = mutableSetOf() + newPaths.addAll(kubeConfigUtils.getAllConfigs()) + stopWatchingRemoved(newPaths) + startWatchingNew(newPaths) + + monitoredPaths.clear() + monitoredPaths.addAll(newPaths) + logger.info("Monitored paths: $monitoredPaths") + } + + private fun stopWatchingRemoved(newPaths: Set) { + (monitoredPaths - newPaths).forEach { path -> + fileWatcher.removeFile(path) + logger.info("Stopped monitoring kubeconfig file: $path") + } + } + + private fun startWatchingNew(newPaths: Set) { + (newPaths - monitoredPaths).forEach { path -> + fileWatcher.addFile(path) + logger.info("Started monitoring kubeconfig file: $path") + } + } + + internal fun refreshClusters() { + logger.info("Reparsing kubeconfig files. Monitored paths: $monitoredPaths") + val allClusters = kubeConfigUtils.getClusters(monitoredPaths.toList()) + scope.launch { + logger.info("Emitting ${allClusters.size} clusters to SharedFlow") + _clusters.emit(allClusters) + } + logger.info("Reparsed kubeconfig files. Found ${allClusters.size} clusters: ${allClusters.map { "${it.name}@${it.url}" }}") + } + + fun onFileChanged(filePath: Path) { + logger.info("Kubeconfig file changed: $filePath. Reparsing and updating clusters.") + refreshClusters() + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/kube/ApiExceptionUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt similarity index 91% rename from src/main/kotlin/com/redhat/devtools/gateway/openshift/kube/ApiExceptionUtils.kt rename to src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt index 88846455..ad188413 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/kube/ApiExceptionUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt @@ -9,7 +9,7 @@ * Contributors: * Red Hat, Inc. - initial API and implementation */ -package com.redhat.devtools.gateway.openshift.kube +package com.redhat.devtools.gateway.openshift import io.kubernetes.client.openapi.ApiException diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt new file mode 100644 index 00000000..162408b5 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.openshift + +data class Cluster( + val id: String, + val name: String, + val url: String, + val token: String? +) { + override fun toString(): String { + return "$name ($url)" + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspaces.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspaces.kt index 770aa364..a6ffa4b4 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspaces.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspaces.kt @@ -229,7 +229,7 @@ class DevWorkspaces(private val client: ApiClient) { @Throws(ApiException::class) private fun doPatch(namespace: String, name: String, body: Any) { PatchUtils.patch( - DevWorkspace.javaClass, + DevWorkspace::class.java, { customApi.patchNamespacedCustomObject( "workspace.devfile.io", diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt index 9d21c271..27dfb013 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt @@ -12,39 +12,50 @@ package com.redhat.devtools.gateway.openshift import com.intellij.openapi.diagnostic.thisLogger -import com.redhat.devtools.gateway.openshift.kube.InvalidKubeConfigException -import com.redhat.devtools.gateway.openshift.kube.KubeConfigBuilder +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.util.ClientBuilder import io.kubernetes.client.util.Config import io.kubernetes.client.util.KubeConfig import java.io.StringReader -class OpenShiftClientFactory() { +class OpenShiftClientFactory(private val kubeConfigBuilder: KubeConfigUtils) { private val userName = "openshift_user" private val contextName = "openshift_context" private val clusterName = "openshift_cluster" + + private var lastUsedKubeConfig: KubeConfig? = null fun create(): ApiClient { - val envKubeConfig = System.getenv("KUBECONFIG") - if (envKubeConfig != null) { - try { - val effectiveConfigYaml = KubeConfigBuilder.fromEnvVar() - val reader = StringReader(effectiveConfigYaml) - val kubeConfig = KubeConfig.loadKubeConfig(reader) - return ClientBuilder.kubeconfig(kubeConfig).build() - } catch (err: InvalidKubeConfigException) { - thisLogger().debug("Failed to build an effective Kube config from `KUBECONFIG` due to error: ${err.message}. Falling back to the default ApiClient.") + val mergedConfig = kubeConfigBuilder.getAllConfigsMerged() + ?: run { + thisLogger().debug("No effective kubeconfig found. Falling back to default ApiClient.") + lastUsedKubeConfig = null + return ClientBuilder.defaultClient() } - } - return ClientBuilder.defaultClient() + return try { + val kubeConfig = KubeConfig.loadKubeConfig(StringReader(mergedConfig)) + lastUsedKubeConfig = kubeConfig + ClientBuilder.kubeconfig(kubeConfig).build() + } catch (e: Exception) { + thisLogger().debug("Failed to build effective Kube config from discovered files due to error: ${e.message}. Falling back to the default ApiClient.") + lastUsedKubeConfig = null + ClientBuilder.defaultClient() + } } fun create(server: String, token: CharArray): ApiClient { val kubeConfig = createKubeConfig(server, token) + lastUsedKubeConfig = kubeConfig return Config.fromConfig(kubeConfig) } + + fun isTokenAuth(): Boolean { + return lastUsedKubeConfig?.let { + KubeConfigUtils.isCurrentUserTokenAuth(it) + } ?: false + } private fun createKubeConfig(server: String, token: CharArray): KubeConfig { val cluster = mapOf( @@ -70,7 +81,6 @@ class OpenShiftClientFactory() { ) ) - val kubeConfig = KubeConfig(arrayListOf(context), arrayListOf(cluster), arrayListOf(user)) kubeConfig.setContext(contextName) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/kube/Cluster.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/kube/Cluster.kt deleted file mode 100644 index e95628bd..00000000 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/kube/Cluster.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (c) 2025 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ -package com.redhat.devtools.gateway.openshift.kube - -data class Cluster( - val name: String, - val url: String -) { - companion object { - private val SCHEMA_REGEX = Regex("^https?://") - private val PATH_REGEX = Regex("/.*$") - - fun fromString(string: String): Cluster? { - return if (isUrl(string)) { - fromUrl(string) - } else { - val match = getUrlAndNameMatch(string) - if (match != null) { - val (name, url) = match.destructured - Cluster(name, url) - } else { - null - } - } - } - - fun toString(cluster: Cluster?): String { - return if (cluster == null) { - "" - } else { - "${cluster.name} (${cluster.url})" - } - } - - private fun isUrl(text: String): Boolean { - return text.startsWith("https://") || text.startsWith("http://") - } - - private fun fromUrl(url: String): Cluster { - val name = url - .replace(SCHEMA_REGEX, "") - .replace(PATH_REGEX, "") - return Cluster(name, url) - } - - private fun getUrlAndNameMatch(text: String): MatchResult? { - return Regex("""^(.+)\s*\((https?://.+)\)$""").find(text) - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/kube/KubeConfigBuilder.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/kube/KubeConfigBuilder.kt deleted file mode 100644 index ec946539..00000000 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/kube/KubeConfigBuilder.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright (c) 2025 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ -package com.redhat.devtools.gateway.openshift.kube - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory -import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator -import java.io.File -import java.nio.file.Paths - -class InvalidKubeConfigException(message: String) : Exception(message) - -object KubeConfigBuilder { - private val yamlMapper = ObjectMapper(YAMLFactory().disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER)) - private val allClusters = mutableListOf>() - private val allContexts = mutableListOf>() - private val allUsers = mutableListOf>() - - fun fromEnvVar(): String = fromConfigs(getKubeconfigEnvPaths()) - - private fun fromDefault(): String = fromConfigs(getDefaultKubeconfigPath()) - - @Suppress("UNCHECKED_CAST") - private fun fromConfigs(files: List): String { - var selectedContextName: String? = null - var preferences: Map? = null - - for (file in files) { - if (!file.exists()) continue - - val config = yamlMapper.readValue(file, Map::class.java) as? Map ?: continue - - (config["clusters"] as? List<*>)?.filterIsInstance>()?.let { allClusters.addAll(it) } - (config["contexts"] as? List<*>)?.filterIsInstance>()?.let { allContexts.addAll(it) } - (config["users"] as? List<*>)?.filterIsInstance>()?.let { allUsers.addAll(it) } - - if (selectedContextName == null) { - selectedContextName = config["current-context"] as? String - } - - if (preferences == null) { - preferences = config["preferences"] as? Map - } - } - - val finalConfig = createConfig(selectedContextName, preferences) - return yamlMapper.writeValueAsString(finalConfig) - } - - private fun createConfig( - selectedContextName: String?, - preferences: Map? - ): MutableMap { - if (selectedContextName == null) { - throw InvalidKubeConfigException("No current-context found in provided kubeconfigs.") - } - - val selectedContext = allContexts.find { it["name"] == selectedContextName } - ?: throw InvalidKubeConfigException("current-context '$selectedContextName' not found in merged contexts") - - val contextObj = selectedContext["context"] as? Map<*, *> - ?: throw InvalidKubeConfigException("Invalid context structure") - val clusterName = contextObj["cluster"] as? String - ?: throw InvalidKubeConfigException("Missing cluster in context") - val userName = contextObj["user"] as? String - ?: throw InvalidKubeConfigException("Missing user in context") - - val selectedCluster = allClusters.find { it["name"] == clusterName } - ?: throw InvalidKubeConfigException("Cluster '$clusterName' referenced in current-context not found") - - val selectedUser = allUsers.find { it["name"] == userName } - ?: throw InvalidKubeConfigException("User '$userName' referenced in current-context not found") - - val finalConfig = mutableMapOf( - "apiVersion" to "v1", - "kind" to "Config", - "current-context" to selectedContextName, - "clusters" to listOf(selectedCluster), - "contexts" to listOf(selectedContext), - "users" to listOf(selectedUser) - ) - - if (preferences != null) { - finalConfig["preferences"] = preferences - } - return finalConfig - } - - private fun getKubeconfigEnvPaths(): List = - System.getenv("KUBECONFIG") - ?.split(File.pathSeparator) - ?.map(String::trim) - ?.filter(String::isNotEmpty) - ?.map(::File) - ?: emptyList() - - private fun getDefaultKubeconfigPath(): List = - listOfNotNull( - Paths.get(System.getProperty("user.home"), ".kube", "config") - .takeIf { it.toFile().exists() && it.toFile().isFile } - ?.toFile() - ) - - @Suppress("UNCHECKED_CAST") - fun isTokenAuthUsed(): Boolean { - return try { - val users = allUsers as? List> ?: return false - val firstUser = users.firstOrNull() ?: return false - val userDetails = firstUser["user"] as? Map ?: return false - val hasDirectToken = userDetails.containsKey("token") - val hasAuthProviderToken = (userDetails["auth-provider"] as? Map)?.containsKey("token") == true - - hasDirectToken || hasAuthProviderToken - } catch (_: Exception) { - false - } - } - - init { - val envKubeConfig = System.getenv("KUBECONFIG") - if (envKubeConfig != null) fromEnvVar() else fromDefault() - } - - fun getClusters(): List { - return allClusters.mapNotNull { cluster -> - val clusterName = cluster["name"] as? String ?: return@mapNotNull null - val clusterDetails = cluster["cluster"] as? Map<*, *> - val serverUrl = clusterDetails?.get("server") as? String ?: return@mapNotNull null - Cluster(clusterName, serverUrl) - } - } - - fun getTokenForCluster(name: String): String? { - val userName = allContexts.find { ctx -> - val context = ctx["context"] as? Map<*, *> - context?.get("cluster") == name - }?.get("context")?.let { it as? Map<*, *> }?.get("user") as? String ?: return null - - val token = allUsers.find { user -> user["name"] == userName }?.get("user") - ?.let { it as? Map<*, *> }?.get("token") as? String - - return token - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt index a4bff278..965bafab 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt @@ -60,7 +60,7 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { ) .trim() .also { status -> - logger().debug("remote server status: $status") + thisLogger().debug("remote server status: $status") return if (status.isEmpty()) { RemoteIDEServerStatus.empty() } else { diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/DevSpacesWizardView.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/DevSpacesWizardView.kt index 1a7621c2..2222e7eb 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/DevSpacesWizardView.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/DevSpacesWizardView.kt @@ -34,7 +34,7 @@ class DevSpacesWizardView(devSpacesContext: DevSpacesContext) : BorderLayoutPane private var nextButton = JButton() init { - steps.add(DevSpacesServerStepView(devSpacesContext)) + steps.add(DevSpacesServerStepView(devSpacesContext) { enableNextButton() }) steps.add(DevSpacesWorkspacesStepView(devSpacesContext) { enableNextButton() }) addToBottom(createButtons()) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt index 8a8c6754..d272fd6a 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt @@ -11,6 +11,7 @@ */ package com.redhat.devtools.gateway.view.steps +import com.intellij.openapi.application.invokeLater import com.intellij.openapi.components.service import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager @@ -21,62 +22,49 @@ import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.redhat.devtools.gateway.DevSpacesBundle import com.redhat.devtools.gateway.DevSpacesContext +import com.redhat.devtools.gateway.kubeconfig.FileWatcher +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils +import com.redhat.devtools.gateway.kubeconfig.KubeconfigMonitor +import com.redhat.devtools.gateway.openshift.Cluster import com.redhat.devtools.gateway.openshift.OpenShiftClientFactory import com.redhat.devtools.gateway.openshift.Projects -import com.redhat.devtools.gateway.openshift.kube.Cluster -import com.redhat.devtools.gateway.openshift.kube.KubeConfigBuilder import com.redhat.devtools.gateway.settings.DevSpacesSettings import com.redhat.devtools.gateway.util.message import com.redhat.devtools.gateway.view.ui.Dialogs import com.redhat.devtools.gateway.view.ui.FilteringComboBox import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu -import io.kubernetes.client.openapi.auth.ApiKeyAuth -import io.kubernetes.client.util.Config -import javax.swing.JComboBox +import kotlinx.coroutines.* import javax.swing.JTextField - +import javax.swing.event.DocumentEvent +import javax.swing.event.DocumentListener class DevSpacesServerStepView( - private var devSpacesContext: DevSpacesContext + private var devSpacesContext: DevSpacesContext, + private val enableNextButton: (() -> Unit)? ) : DevSpacesWizardStep { - private val allServers = KubeConfigBuilder.getClusters() + private val settings: ServerSettings = ServerSettings() + private val kubeconfigScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private lateinit var kubeconfigMonitor: KubeconfigMonitor + private var tfToken = JBTextField() - .apply { PasteClipboardMenu.addTo(this) } - private var tfServer: JComboBox = + .apply { + document.addDocumentListener(onTokenChanged()) + PasteClipboardMenu.addTo(this) + } + + private var tfServer = FilteringComboBox.create( - allServers, - Cluster::toString, - Cluster::fromString, + { it?.toString() ?: "" }, + { name, clusters -> clusters.firstOrNull { it.name == name } }, Cluster::class.java ) { cluster -> if (cluster != null) { - val token = KubeConfigBuilder.getTokenForCluster(cluster.name) ?: "" - tfToken.text = token + tfToken.text = cluster.token ?: "" } - }.apply { PasteClipboardMenu.addTo(this.editor.editorComponent as JTextField) } - - private var settingsAreLoaded = false - private val settings = service() - - /** - * Checks if both server and token fields have content - */ - private fun areServerAndTokenValid(): Boolean { - if (tfServer.selectedItem == null) { - return false + }.apply { + PasteClipboardMenu.addTo(this.editor.editorComponent as JTextField) } - - return !tfToken.text.isNullOrBlank() - } - - override fun isNextEnabled(): Boolean { - return areServerAndTokenValid() - } - - override val nextActionText = DevSpacesBundle.message("connector.wizard_step.openshift_connection.button.next") - override val previousActionText = - DevSpacesBundle.message("connector.wizard_step.openshift_connection.button.previous") override val component = panel { row { label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.title")).applyToComponent { @@ -93,12 +81,44 @@ class DevSpacesServerStepView( background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() border = JBUI.Borders.empty(8) } + override val nextActionText = DevSpacesBundle.message("connector.wizard_step.openshift_connection.button.next") + override val previousActionText = + DevSpacesBundle.message("connector.wizard_step.openshift_connection.button.previous") override fun onInit() { - loadOpenShiftConnectionSettings() + kubeconfigMonitor = KubeconfigMonitor(kubeconfigScope, FileWatcher(kubeconfigScope), KubeConfigUtils) + + kubeconfigScope.launch { + kubeconfigMonitor.onClustersCollected(onClustersChanged()) + } + + kubeconfigMonitor.start() + } + + private fun onTokenChanged(): DocumentListener = object : DocumentListener { + override fun insertUpdate(event: DocumentEvent) { + enableNextButton?.invoke() + } + + override fun removeUpdate(e: DocumentEvent) { + enableNextButton?.invoke() + } + + override fun changedUpdate(e: DocumentEvent?) { + enableNextButton?.invoke() + } + } + + private fun onClustersChanged(): suspend (List) -> Unit = { updatedClusters -> + invokeLater { + val selectedName = (tfServer.selectedItem as? Cluster)?.name + setClusters(updatedClusters) + setSelectedCluster(selectedName, updatedClusters) + } } override fun onPrevious(): Boolean { + kubeconfigScope.cancel() return true } @@ -106,8 +126,9 @@ class DevSpacesServerStepView( val selectedCluster = tfServer.selectedItem as? Cluster ?: return false val server = selectedCluster.url val token = tfToken.text - val client = OpenShiftClientFactory().create(server, token.toCharArray()) + val client = OpenShiftClientFactory(KubeConfigUtils).create(server, token.toCharArray()) var success = false + kubeconfigScope.cancel() ProgressManager.getInstance().runProcessWithProgressSynchronously( { @@ -125,44 +146,49 @@ class DevSpacesServerStepView( ) if (success) { - saveOpenShiftConnectionSettings() + settings.save(tfServer.selectedItem as? Cluster) devSpacesContext.client = client } return success } - private fun loadOpenShiftConnectionSettings() { - tfServer.removeAllItems() - allServers.forEach { tfServer.addItem(it) } - - try { - val config = Config.defaultClient() - val matchingCluster = allServers.find { it.url == config.basePath } - if (matchingCluster != null) { - tfServer.selectedItem = matchingCluster - } - val auth = config.authentications["BearerToken"] - if (auth is ApiKeyAuth) tfToken.text = auth.apiKey - } catch (_: Exception) { - // Do nothing + override fun isNextEnabled(): Boolean { + return if (tfServer.selectedItem == null) { + false + } else { + !tfToken.text.isNullOrBlank() } + } - if (tfServer.selectedItem == null || tfToken.text.isEmpty()) { - val matchingCluster = allServers.find { it.url == settings.state.server.orEmpty() } - if (matchingCluster != null) { - tfServer.selectedItem = matchingCluster - } - tfToken.text = settings.state.token.orEmpty() - settingsAreLoaded = true + private fun setClusters(clusters: List) { + this.tfServer.removeAllItems() + clusters.forEach { + tfServer.addItem(it) } } - private fun saveOpenShiftConnectionSettings() { - if (settingsAreLoaded) { - val selectedCluster = tfServer.selectedItem as Cluster - settings.state.server = selectedCluster.url - settings.state.token = tfToken.text + private fun setSelectedCluster(name: String?, clusters: List) { + tfServer.selectedItem = null // Reset selectedItem + val saved = settings.load(clusters) + val toSelect = clusters.find { it.name == name } + ?: clusters.firstOrNull { it.id == saved?.id } + ?: clusters.firstOrNull() + tfServer.selectedItem = toSelect + tfToken.text = toSelect?.token ?: "" + } + + private class ServerSettings { + + val service = service() + + fun load(clusters: List): Cluster? { + return clusters.find { it.url == service.state.server.orEmpty() } + } + + fun save(toSave: Cluster?) { + val cluster = toSave ?: return + service.state.server = cluster.url } } } \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/ui/FilteringComboBox.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/ui/FilteringComboBox.kt index 53a7e88c..208b0eca 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/ui/FilteringComboBox.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/ui/FilteringComboBox.kt @@ -16,65 +16,51 @@ import java.awt.event.ActionListener import java.awt.event.ItemEvent import java.awt.event.KeyAdapter import java.awt.event.KeyEvent -import javax.swing.ComboBoxEditor -import javax.swing.DefaultComboBoxModel -import javax.swing.JComboBox -import javax.swing.JComponent -import javax.swing.JLabel -import javax.swing.JTextField -import javax.swing.ListCellRenderer -import javax.swing.SwingUtilities -import javax.swing.UIManager +import javax.swing.* import javax.swing.event.PopupMenuEvent import javax.swing.event.PopupMenuListener import javax.swing.text.JTextComponent - object FilteringComboBox { private val popupOpened = PopupOpened() fun create( - allItems: List, toString: (T?) -> String = { it.toString() }, - toType: ((String) -> T?)? = null, + matchItem: ((expression: String, toMatch: List) -> T?)? = null, type: Class, - onItemUpdated: (T?) -> Unit + onItemSelected: (T?) -> Unit ): JComboBox { val comboBox = JComboBox() comboBox.isEditable = true - comboBox.editor = UnsettableComboBoxEditor(allItems, toString, toType) + comboBox.editor = UnsettableComboBoxEditor(comboBox, toString, matchItem) popupOpened.reset(comboBox) - val model = DefaultComboBoxModel() - allItems.forEach { model.addElement(it) } - comboBox.model = model + comboBox.model = FilteringComboBoxModel() comboBox.setRenderer(onListItemRendered(toString)) - comboBox.addPopupMenuListener(onPopupVisible(allItems, comboBox, toString)) + comboBox.addPopupMenuListener(onPopupVisible(comboBox, toString)) val editor = getEditor(comboBox) - editor?.addKeyListener(onKeyPressed(editor, comboBox, allItems, toString)) + editor?.addKeyListener(onKeyPressed(editor, comboBox, toString)) - comboBox.addItemListener(onItemSelected(editor, onItemUpdated, toString, type)) + comboBox.addItemListener(onItemSelected(editor, onItemSelected, toString, type)) return comboBox } private fun onPopupVisible( - allItems: List, comboBox: JComboBox, toString: (T) -> String ): PopupMenuListener = object : PopupMenuListener { override fun popupMenuWillBecomeVisible(e: PopupMenuEvent?) { + val allItems = comboBox.filteringModel().getAllItems() val editorText = getEditor(comboBox)?.text ?: "" - val model = DefaultComboBoxModel() - val items = if (popupOpened.isProgrammatic(comboBox)) { + val visible = if (popupOpened.isProgrammatic(comboBox)) { filterItems(editorText, allItems, toString) } else { allItems } - items.forEach { model.addElement(it) } - comboBox.model = model + comboBox.filteringModel().showOnly(visible) comboBox.selectedIndex = -1 // prevent item selection popupOpened.reset(comboBox) } @@ -129,7 +115,6 @@ object FilteringComboBox { private fun onKeyPressed( editor: JTextField, comboBox: JComboBox, - allItems: List, toString: (T) -> String ): KeyAdapter = object : KeyAdapter() { override fun keyReleased(e: KeyEvent) { @@ -137,7 +122,7 @@ object FilteringComboBox { return } val currentText = editor.text - val filtered = filterItems(currentText, allItems, toString) + val filtered = filterItems(currentText, comboBox.filteringModel().getAllItems(), toString) showPopup(comboBox, filtered) } } @@ -151,10 +136,8 @@ object FilteringComboBox { val selection = Selection(comboBox.editor.editorComponent as? JTextComponent).backup() val currentTextInEditor = editor?.text ?: "" - val model = DefaultComboBoxModel() - items.forEach { model.addElement(it) } + comboBox.filteringModel().showOnly(items) - comboBox.model = model editor?.text = currentTextInEditor // Restore the selection after the model and text are set @@ -183,10 +166,14 @@ object FilteringComboBox { ) } + private fun JComboBox.filteringModel(): FilteringComboBoxModel { + return model as FilteringComboBoxModel + } + private class UnsettableComboBoxEditor( - private val allItems: List, + private val comboBox: JComboBox, private val toString: (T?) -> String, - private val toType: ((String) -> T)? + private val matchItem: ((expression: String, toMatch: List) -> T?)? ) : ComboBoxEditor { private val textField = JTextField() @@ -202,8 +189,9 @@ object FilteringComboBox { override fun getItem(): Any? { val text = textField.text + val allItems = comboBox.filteringModel().getAllItems() val matchingItem = allItems.find { toString(it) == text } - return matchingItem ?: (toType?.invoke(text) ?: text) + return matchingItem ?: (matchItem?.invoke(text, allItems) ?: text) } override fun selectAll() { @@ -262,7 +250,7 @@ object FilteringComboBox { } } - class PopupOpened() { + private class PopupOpened() { private val key = Key.create("isPopupProgrammatic") @@ -279,5 +267,45 @@ object FilteringComboBox { setProgrammatic(false, component) } } + + private class FilteringComboBoxModel() : DefaultComboBoxModel() { + + private val hidden = mutableSetOf() + + override fun getIndexOf(item: Any?): Int { + return getAllVisible().indexOf(item) + } + + override fun getSize(): Int { + return getAllItems() + .filter { it !in hidden }.size + } + + override fun getElementAt(index: Int): T? { + return getAllVisible() + .elementAtOrNull(index) + } + + override fun removeAllElements() { + hidden.clear() + super.removeAllElements() + } + + fun showOnly(items: List) { + val toHide = getAllItems().filter { !items.contains(it) } + hidden.clear() + hidden.addAll(toHide) + fireContentsChanged(this, 0, size - 1) + } + + private fun getAllVisible(): List { + return getAllItems().filter { it !in hidden } + } + + fun getAllItems(): List { + return (0 until super.size) + .map { super.getElementAt(it) } + } + } } diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcherTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcherTest.kt new file mode 100644 index 00000000..c77c9f15 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcherTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.kubeconfig + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout + + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.io.path.createFile +import kotlin.io.path.writeText + +@ExperimentalCoroutinesApi +class FileWatcherTest { + + @TempDir + lateinit var tempDir: Path + + private lateinit var testFile: Path + private lateinit var testDispatcher: TestDispatcher + private lateinit var testScope: TestScope + private lateinit var watcher: FileWatcher + + @BeforeEach + fun beforeEach() { + testFile = createFile("test-kubeconfig.yaml", "initial content") + testDispatcher = StandardTestDispatcher() + testScope = TestScope(testDispatcher) + watcher = FileWatcher(testScope, testDispatcher) + } + + @AfterEach + fun afterEach() { + watcher.stop() + } + + @Test + fun `#addFile() invokes callback when a file is added`() = runTest { + // given + var onFileChangedCount = 0 + watcher.onFileChanged { onFileChangedCount++ } + + // when + watcher.addFile(testFile) + advanceUntilIdle() + + // then - Initial notification when file is added + assertThat(onFileChangedCount).isEqualTo(1) + } + + @Test + fun `#addFile() does not invoke callback for non-existent files`() = runTest { + // given + var onFileChangedCount = 0 + watcher.onFileChanged { onFileChangedCount++ } + val nonExistentFile = tempDir.resolve("non-existent.yaml") + + // when + watcher.addFile(nonExistentFile) + advanceUntilIdle() + + // then - No callback should be invoked for non-existent files + assertThat(onFileChangedCount).isEqualTo(0) + } + + @Test + fun `#addFile() invokes callback when multiple files are added`() = runTest { + // given + var onFileChangedCount = 0 + watcher.onFileChanged { onFileChangedCount++ } + val secondFile = createFile("second-file.yaml","second content") + + // when + watcher + .addFile(testFile) + .addFile(secondFile) + advanceUntilIdle() + + // then - Callback should be invoked for both files + assertThat(onFileChangedCount).isEqualTo(2) + } + + @Test + fun `#removeFile() removes file from monitoring`() = runTest { + // given + var onFileChangedCount = 0 + watcher.onFileChanged { onFileChangedCount++ } + + // when - add file and verify it triggers callback + watcher.addFile(testFile) + advanceUntilIdle() + assertThat(onFileChangedCount).isEqualTo(1) + + // when - remove file from monitoring + watcher.removeFile(testFile) + advanceUntilIdle() + + val initialOnFileChangedCount = onFileChangedCount + + // Re-adding the same file should trigger callback again since it was removed + watcher.addFile(testFile) + advanceUntilIdle() + + assertThat(onFileChangedCount).isEqualTo(initialOnFileChangedCount + 1) + } + + @Test + fun `#removeFile() handles non-existent files gracefully`() = runTest { + // given + val nonExistentFile = tempDir.resolve("never-added.yaml") + + // when - removing a file that was never added + val result = watcher.removeFile(nonExistentFile) + + // then - should return the watcher instance and not throw any exceptions + assertThat(result).isSameAs(watcher) + } + + @Test + fun `#removeFile() keeps other files monitored`() = runTest { + // given + val secondFile = createFile("second-file.yaml", "second content") + val thirdFile = createFile("third-file.yaml", "third content") + var onFileChangedCount = 0 + watcher.onFileChanged { onFileChangedCount++ } + + // when - add multiple files + watcher + .addFile(testFile) + .addFile(secondFile) + .addFile(thirdFile) + advanceUntilIdle() + + // then - all files should trigger callbacks + assertThat(onFileChangedCount).isEqualTo(3) + + // when - remove one file + watcher.removeFile(secondFile) + advanceUntilIdle() + + // then - other files should still be monitored + val initialOnFileChangedCount = onFileChangedCount + watcher.addFile(testFile) // Re-adding should not trigger (already added) + watcher.addFile(thirdFile) // Re-adding should not trigger (already added) + watcher.addFile(secondFile) // Adding should trigger (was removed) + advanceUntilIdle() + + assertThat(onFileChangedCount).isEqualTo(initialOnFileChangedCount + 1) + } + + @Test + fun `#onFileChanged() is invoked when a watched file is modified`() = runTest { + // given + val callbackReceived = CompletableDeferred() + watcher.onFileChanged { + callbackReceived.complete(Unit) + } + + watcher.start() + watcher.addFile(testFile) + advanceUntilIdle() + + // when + testFile.writeText("updated content") + + // then + withTimeout(1000) { + callbackReceived.await() + } + } + + private fun createFile(filename: String, content: String): Path { + val file = tempDir.resolve(filename) + file.createFile() + file.writeText(content) + return file + } + +} \ No newline at end of file diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntriesTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntriesTest.kt new file mode 100644 index 00000000..448506fd --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntriesTest.kt @@ -0,0 +1,847 @@ +package com.redhat.devtools.gateway.kubeconfig + +import io.kubernetes.client.util.KubeConfig +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class KubeConfigEntriesTest { + + @Nested + inner class KubeConfigClusterTest { + + @Test + fun `#fromMap is parsing cluster with all fields`() { + // given + val map = mapOf( + "server" to "https://api.example.com:6443", + "certificate-authority-data" to "LS0tLS1CRUdJTi...", + "insecure-skip-tls-verify" to true + ) + + // when + val cluster = KubeConfigCluster.fromMap(map) + + // then + Assertions.assertThat(cluster).isNotNull + Assertions.assertThat(cluster?.server).isEqualTo("https://api.example.com:6443") + Assertions.assertThat(cluster?.certificateAuthorityData).isEqualTo("LS0tLS1CRUdJTi...") + Assertions.assertThat(cluster?.insecureSkipTlsVerify).isTrue() + } + + @Test + fun `#fromMap is parsing cluster with only server`() { + // given + val map = mapOf( + "server" to "https://api.example.com:6443" + ) + + // when + val cluster = KubeConfigCluster.fromMap(map) + + // then + Assertions.assertThat(cluster).isNotNull + Assertions.assertThat(cluster?.server).isEqualTo("https://api.example.com:6443") + Assertions.assertThat(cluster?.certificateAuthorityData).isNull() + Assertions.assertThat(cluster?.insecureSkipTlsVerify).isNull() + } + + @Test + fun `#fromMap returns null when server is missing`() { + // given + val map = mapOf( + "certificate-authority-data" to "LS0tLS1CRUdJTi..." + ) + + // when + val cluster = KubeConfigCluster.fromMap(map) + + // then + Assertions.assertThat(cluster).isNull() + } + + @Test + fun `#fromMap returns null for empty map`() { + // given + // empty map + + // when + val cluster = KubeConfigCluster.fromMap(emptyMap()) + + // then + Assertions.assertThat(cluster).isNull() + } + + @Test + fun `#fromMap is handling non-string server value gracefully`() { + // given + val map = mapOf( + "server" to 12345 // non-string value + ) + + // when + val cluster = KubeConfigCluster.fromMap(map) + + // then + Assertions.assertThat(cluster).isNull() + } + + @Test + fun `#fromMap handles non-boolean insecure-skip-tls-verify value gracefully`() { + // given + val map = mapOf( + "server" to "https://api.example.com:6443", + "insecure-skip-tls-verify" to "not-a-boolean" + ) + + // when + val cluster = KubeConfigCluster.fromMap(map) + + // then + Assertions.assertThat(cluster).isNotNull + Assertions.assertThat(cluster?.insecureSkipTlsVerify).isNull() + } + } + + @Nested + inner class KubeConfigNamedClusterTest { + + @Test + fun `#fromMap is parsing named cluster`() { + // given + val clusterObject = mapOf( + "cluster" to mapOf( + "server" to "https://api.example.com:6443", + "certificate-authority-data" to "LS0tLS1CRUdJTi..." + ) + ) + + // when + val namedCluster = KubeConfigNamedCluster.fromMap("my-cluster", clusterObject) + + // then + Assertions.assertThat(namedCluster).isNotNull + Assertions.assertThat(namedCluster?.name).isEqualTo("my-cluster") + Assertions.assertThat(namedCluster?.cluster?.server).isEqualTo("https://api.example.com:6443") + } + + @Test + fun `#fromMap returns null when cluster details are invalid`() { + // given + val clusterObject = mapOf( + "cluster" to mapOf( + "invalid" to "data" + ) + ) + + // when + val namedCluster = KubeConfigNamedCluster.fromMap("my-cluster", clusterObject) + + // then + Assertions.assertThat(namedCluster).isNull() + } + + @Test + fun `#fromMap returns null when cluster key is missing`() { + // given + val clusterObject = mapOf( + "name" to "my-cluster" + ) + + // when + val namedCluster = KubeConfigNamedCluster.fromMap("my-cluster", clusterObject) + + // then + Assertions.assertThat(namedCluster).isNull() + } + + @Test + fun `#fromKubeConfig is parsing multiple clusters`() { + // given + val kubeConfig = mockk() + every { kubeConfig.clusters } returns arrayListOf( + mapOf( + "name" to "skywalker", + "cluster" to mapOf("server" to "https://api1.example.com:6443") + ), + mapOf( + "name" to "darth-vader", + "cluster" to mapOf("server" to "https://api2.example.com:6443") + ) + ) + + // when + val clusters = KubeConfigNamedCluster.fromKubeConfig(kubeConfig) + + // then + Assertions.assertThat(clusters).hasSize(2) + Assertions.assertThat(clusters[0].name).isEqualTo("skywalker") + Assertions.assertThat(clusters[0].cluster.server).isEqualTo("https://api1.example.com:6443") + Assertions.assertThat(clusters[1].name).isEqualTo("darth-vader") + Assertions.assertThat(clusters[1].cluster.server).isEqualTo("https://api2.example.com:6443") + } + + @Test + fun `#fromKubeConfig skips invalid clusters`() { + // given + val kubeConfig = mockk() + every { kubeConfig.clusters } returns arrayListOf( + mapOf( + "name" to "luke", + "cluster" to mapOf("server" to "https://api1.example.com:6443") + ), + mapOf( + "name" to "invalid-cluster" + // missing cluster details + ), + mapOf( + "name" to "leia", + "cluster" to mapOf("server" to "https://api2.example.com:6443") + ) + ) + + // when + val clusters = KubeConfigNamedCluster.fromKubeConfig(kubeConfig) + + // then + Assertions.assertThat(clusters).hasSize(2) + Assertions.assertThat(clusters.map { it.name }).containsExactly("luke", "leia") + } + + @Test + fun `#fromKubeConfig returns empty list when clusters is null`() { + // given + val kubeConfig = mockk() + every { kubeConfig.clusters } returns null + + // when + val clusters = KubeConfigNamedCluster.fromKubeConfig(kubeConfig) + + // then + Assertions.assertThat(clusters).isEmpty() + } + + @Test + fun `#fromMap returns null when clusterObject is not a Map`() { + // given + // invalid clusterObject (string instead of map) + + // when + val namedCluster = KubeConfigNamedCluster.fromMap("my-cluster", "not-a-map") + + // then + Assertions.assertThat(namedCluster).isNull() + } + + @Test + fun `#fromMap returns null when clusterObject is null`() { + // given + // null clusterObject + + // when + val namedCluster = KubeConfigNamedCluster.fromMap("my-cluster", null) + + // then + Assertions.assertThat(namedCluster).isNull() + } + + @Test + fun `#fromKubeConfig is handling clusters with missing name`() { + // given + val kubeConfig = mockk() + every { kubeConfig.clusters } returns arrayListOf( + mapOf( + "cluster" to mapOf("server" to "https://api1.example.com:6443") + // missing "name" field + ), + mapOf( + "name" to "darth-vader", + "cluster" to mapOf("server" to "https://api2.example.com:6443") + ) + ) + + // when + val clusters = KubeConfigNamedCluster.fromKubeConfig(kubeConfig) + + // then + Assertions.assertThat(clusters).hasSize(1) + Assertions.assertThat(clusters[0].name).isEqualTo("darth-vader") + } + + @Test + fun `#fromKubeConfig is handling non-map cluster objects`() { + // given + val kubeConfig = mockk() + every { kubeConfig.clusters } returns arrayListOf( + "not-a-map", // invalid cluster object + mapOf( + "name" to "skywalker", + "cluster" to mapOf("server" to "https://api1.example.com:6443") + ) + ) + + // when + val clusters = KubeConfigNamedCluster.fromKubeConfig(kubeConfig) + + // then + Assertions.assertThat(clusters).hasSize(1) + Assertions.assertThat(clusters[0].name).isEqualTo("skywalker") + } + } + + @Nested + inner class KubeConfigContextTest { + + @Test + fun `#fromMap is parsing context with all fields`() { + // given + val map = mapOf( + "cluster" to "my-cluster", + "user" to "my-user", + "namespace" to "my-namespace" + ) + + // when + val context = KubeConfigContext.fromMap(map) + + // then + Assertions.assertThat(context).isNotNull + Assertions.assertThat(context?.cluster).isEqualTo("my-cluster") + Assertions.assertThat(context?.user).isEqualTo("my-user") + Assertions.assertThat(context?.namespace).isEqualTo("my-namespace") + } + + @Test + fun `#fromMap is parsing context without namespace`() { + // given + val map = mapOf( + "cluster" to "my-cluster", + "user" to "my-user" + ) + + // when + val context = KubeConfigContext.fromMap(map) + + // then + Assertions.assertThat(context).isNotNull + Assertions.assertThat(context?.cluster).isEqualTo("my-cluster") + Assertions.assertThat(context?.user).isEqualTo("my-user") + Assertions.assertThat(context?.namespace).isNull() + } + + @Test + fun `#fromMap returns null when cluster is missing`() { + // given + val map = mapOf( + "user" to "my-user" + ) + + // when + val context = KubeConfigContext.fromMap(map) + + // then + Assertions.assertThat(context).isNull() + } + + @Test + fun `#fromMap returns null when user is missing`() { + // given + val map = mapOf( + "cluster" to "my-cluster" + ) + + // when + val context = KubeConfigContext.fromMap(map) + + // then + Assertions.assertThat(context).isNull() + } + + @Test + fun `#fromMap returns null when cluster is not a string`() { + // given + val map = mapOf( + "cluster" to 12345, // non-string value + "user" to "my-user" + ) + + // when + val context = KubeConfigContext.fromMap(map) + + // then + Assertions.assertThat(context).isNull() + } + + @Test + fun `#fromMap returns null when user is not a string`() { + // given + val map = mapOf( + "cluster" to "my-cluster", + "user" to listOf("not", "a", "string") // non-string value + ) + + // when + val context = KubeConfigContext.fromMap(map) + + // then + Assertions.assertThat(context).isNull() + } + + @Test + fun `#fromMap is handling handle non-string namespace gracefully`() { + // given + val map = mapOf( + "cluster" to "my-cluster", + "user" to "my-user", + "namespace" to 42 // non-string namespace + ) + + // when + val context = KubeConfigContext.fromMap(map) + + // then + Assertions.assertThat(context).isNotNull + Assertions.assertThat(context?.cluster).isEqualTo("my-cluster") + Assertions.assertThat(context?.user).isEqualTo("my-user") + Assertions.assertThat(context?.namespace).isNull() + } + } + + @Nested + inner class KubeConfigNamedContextTest { + + @Test + fun `#getKubeConfigNamedContext finds context for cluster`() { + // given + val kubeConfig = mockk() + every { kubeConfig.contexts } returns arrayListOf( + mapOf( + "name" to "skywalker-context", + "context" to mapOf( + "cluster" to "skywalker-cluster", + "user" to "skywalker" + ) + ), + mapOf( + "name" to "darth-vader-context", + "context" to mapOf( + "cluster" to "darth-vader-context", + "user" to "darth-vader" + ) + ) + ) + + // when + val namedContext = KubeConfigNamedContext.getByClusterName("skywalker-cluster", kubeConfig) + + // then + Assertions.assertThat(namedContext).isNotNull + Assertions.assertThat(namedContext?.name).isEqualTo("skywalker-context") + Assertions.assertThat(namedContext?.context?.cluster).isEqualTo("skywalker-cluster") + Assertions.assertThat(namedContext?.context?.user).isEqualTo("skywalker") + } + + @Test + fun `#getKubeConfigNamedContext returns null when cluster not found`() { + // given + val kubeConfig = mockk() + every { kubeConfig.contexts } returns arrayListOf( + mapOf( + "name" to "skywalker-context", + "context" to mapOf( + "cluster" to "skywalker-cluster", + "user" to "skywalker" + ) + ) + ) + + val namedContext = KubeConfigNamedContext.getByClusterName("nonexistent", kubeConfig) + + Assertions.assertThat(namedContext).isNull() + } + + @Test + fun `#getKubeConfigNamedContext returns null when contexts is null`() { + // given + val kubeConfig = mockk() + every { kubeConfig.contexts } returns null + + val namedContext = KubeConfigNamedContext.getByClusterName("skywalker", kubeConfig) + + Assertions.assertThat(namedContext).isNull() + } + + @Test + fun `#getKubeConfigNamedContext is handling contexts with missing context details`() { + // given + val kubeConfig = mockk() + every { kubeConfig.contexts } returns arrayListOf( + mapOf( + "name" to "skywalker-context" + // missing "context" field + ), + mapOf( + "name" to "darth-vader-context", + "context" to mapOf( + "cluster" to "darth-vader-cluster", + "user" to "darth-vader" + ) + ) + ) + + val namedContext = KubeConfigNamedContext.getByClusterName("darth-vader-cluster", kubeConfig) + + Assertions.assertThat(namedContext).isNotNull + Assertions.assertThat(namedContext?.name).isEqualTo("darth-vader-context") + } + + @Test + fun `#getKubeConfigNamedContext is handling non-map context objects`() { + // given + val kubeConfig = mockk() + every { kubeConfig.contexts } returns arrayListOf( + "not-a-map", // invalid context object + mapOf( + "name" to "skywalker-context", + "context" to mapOf( + "cluster" to "skywalker-cluster", + "user" to "skywalker" + ) + ) + ) + + val namedContext = KubeConfigNamedContext.getByClusterName("skywalker-cluster", kubeConfig) + + Assertions.assertThat(namedContext).isNotNull + Assertions.assertThat(namedContext?.name).isEqualTo("skywalker-context") + } + + @Test + fun `#getKubeConfigNamedContext is handling contexts with missing names`() { + // given + val kubeConfig = mockk() + every { kubeConfig.contexts } returns arrayListOf( + mapOf( + "context" to mapOf( + "cluster" to "skywalker-cluster", + "user" to "skywalker" + ) + // missing "name" field + ), + mapOf( + "name" to "darth-vader-context", + "context" to mapOf( + "cluster" to "darth-vader-cluster", + "user" to "darth-vader" + ) + ) + ) + + val namedContext = KubeConfigNamedContext.getByClusterName("darth-vader-cluster", kubeConfig) + + Assertions.assertThat(namedContext).isNotNull + Assertions.assertThat(namedContext?.name).isEqualTo("darth-vader-context") + } + } + + @Nested + inner class KubeConfigUserTest { + + @Test + fun `#fromMap is parsing user with token`() { + // given + val map = mapOf( + "token" to "my-secret-token" + ) + + // when + val user = KubeConfigUser.fromMap(map) + + // then + Assertions.assertThat(user.token).isEqualTo("my-secret-token") + Assertions.assertThat(user.clientCertificateData).isNull() + Assertions.assertThat(user.clientKeyData).isNull() + Assertions.assertThat(user.username).isNull() + Assertions.assertThat(user.password).isNull() + } + + @Test + fun `#fromMap is parsing user with all fields`() { + // given + val map = mapOf( + "token" to "my-secret-token", + "client-certificate-data" to "cert-data", + "client-key-data" to "key-data", + "username" to "admin", + "password" to "secret" + ) + + // when + val user = KubeConfigUser.fromMap(map) + + // then + Assertions.assertThat(user.token).isEqualTo("my-secret-token") + Assertions.assertThat(user.clientCertificateData).isEqualTo("cert-data") + Assertions.assertThat(user.clientKeyData).isEqualTo("key-data") + Assertions.assertThat(user.username).isEqualTo("admin") + Assertions.assertThat(user.password).isEqualTo("secret") + } + + @Test + fun `#fromMap returns empty user for empty map`() { + // given + // empty map + + // when + val user = KubeConfigUser.fromMap(emptyMap()) + + Assertions.assertThat(user.token).isNull() + Assertions.assertThat(user.clientCertificateData).isNull() + Assertions.assertThat(user.clientKeyData).isNull() + Assertions.assertThat(user.username).isNull() + Assertions.assertThat(user.password).isNull() + } + + @Test + fun `#fromMap is handling non-string values gracefully`() { + // given + val map = mapOf( + "token" to 12345, // non-string + "client-certificate-data" to listOf("not", "string"), // non-string + "client-key-data" to true, // non-string + "username" to mapOf("not" to "string"), // non-string + "password" to 3.14 // non-string + ) + + val user = KubeConfigUser.fromMap(map) + + // All should be null since they're not strings + Assertions.assertThat(user.token).isNull() + Assertions.assertThat(user.clientCertificateData).isNull() + Assertions.assertThat(user.clientKeyData).isNull() + Assertions.assertThat(user.username).isNull() + Assertions.assertThat(user.password).isNull() + } + } + + @Nested + inner class KubeConfigNamedUserTest { + + @Test + fun `#fromMap is parsing named user`() { + // given + val userObject = mapOf( + "user" to mapOf( + "token" to "my-secret-token" + ) + ) + + // when + val namedUser = KubeConfigNamedUser.fromMap("my-user", userObject) + + // then + Assertions.assertThat(namedUser).isNotNull + Assertions.assertThat(namedUser?.name).isEqualTo("my-user") + Assertions.assertThat(namedUser?.user?.token).isEqualTo("my-secret-token") + } + + @Test + fun `#fromMap returns null when user key is missing`() { + // given + val userObject = mapOf( + "name" to "my-user" + ) + + // when + val namedUser = KubeConfigNamedUser.fromMap("my-user", userObject) + + // then + Assertions.assertThat(namedUser).isNull() + } + + @Test + fun `#isTokenAuth returns true when current user has token`() { + // given + val kubeConfig = mockk() + every { kubeConfig.credentials } returns mapOf(KubeConfig.CRED_TOKEN_KEY to "Help me, Obi-Wan Kenobi") + + // when + val isTokenAuth = KubeConfigNamedUser.isTokenAuth(kubeConfig) + + // then + Assertions.assertThat(isTokenAuth).isTrue() + } + + @Test + fun `#isTokenAuth returns false when current user has no token`() { + // given + val kubeConfig = mockk() + every { kubeConfig.credentials } returns emptyMap() + + // when + val isTokenAuth = KubeConfigNamedUser.isTokenAuth(kubeConfig) + + // then + Assertions.assertThat(isTokenAuth).isFalse() + } + + @Test + fun `#isTokenAuth returns false when current user is null`() { + val kubeConfig = mockk() + every { kubeConfig.credentials } returns null + + // when + val isTokenAuth = KubeConfigNamedUser.isTokenAuth(kubeConfig) + + // then + Assertions.assertThat(isTokenAuth).isFalse() + } + + @Test + fun `#findUserTokenForCluster finds token for cluster`() { + // given + val kubeConfig = mockk() + every { kubeConfig.contexts } returns arrayListOf( + mapOf( + "name" to "skywalker-context", + "context" to mapOf( + "cluster" to "skywalker-cluster", + "user" to "skywalker" + ) + ) + ) + every { kubeConfig.users } returns arrayListOf( + mapOf( + "name" to "skywalker", + "user" to mapOf("token" to "secret-token-123") + ) + ) + + // when + val token = KubeConfigNamedUser.getUserTokenForCluster("skywalker-cluster", kubeConfig) + + // then + Assertions.assertThat(token).isEqualTo("secret-token-123") + } + + @Test + fun `#findUserTokenForCluster returns null when context not found`() { + // given + val kubeConfig = mockk() + every { kubeConfig.contexts } returns arrayListOf( + mapOf( + "name" to "skywalker-context", + "context" to mapOf( + "cluster" to "skywalker-cluster", + "user" to "skywalker" + ) + ) + ) + + // when + val token = KubeConfigNamedUser.getUserTokenForCluster("nonexistent", kubeConfig) + + Assertions.assertThat(token).isNull() + } + + @Test + fun `#findUserTokenForCluster returns null when user not found`() { + // given + val kubeConfig = mockk() + every { kubeConfig.contexts } returns arrayListOf( + mapOf( + "name" to "skywalker-context", + "context" to mapOf( + "cluster" to "skywalker-cluster", + "user" to "skywalker" + ) + ) + ) + every { kubeConfig.users } returns arrayListOf( + mapOf( + "name" to "different-user", + "user" to mapOf("token" to "secret-token-123") + ) + ) + + // when + val token = KubeConfigNamedUser.getUserTokenForCluster("skywalker-cluster", kubeConfig) + + // then + Assertions.assertThat(token).isNull() + } + + @Test + fun `#findUserTokenForCluster returns null when user has no token`() { + // given + val kubeConfig = mockk() + every { kubeConfig.contexts } returns arrayListOf( + mapOf( + "name" to "skywalker-context", + "context" to mapOf( + "cluster" to "skywalker-cluster", + "user" to "skywalker" + ) + ) + ) + every { kubeConfig.users } returns arrayListOf( + mapOf( + "name" to "skywalker", + "user" to mapOf("client-certificate-data" to "cert") + ) + ) + + // when + val token = KubeConfigNamedUser.getUserTokenForCluster("skywalker-cluster", kubeConfig) + + // then + Assertions.assertThat(token).isNull() + } + + @Test + fun `#fromMap returns null when userObject is not a Map`() { + // given + // invalid userObject (string instead of map) + + // when + val namedUser = KubeConfigNamedUser.fromMap("my-user", "not-a-map") + + Assertions.assertThat(namedUser).isNull() + } + + @Test + fun `#fromMap returns null when userObject is null`() { + // given + // null userObject + + // when + val namedUser = KubeConfigNamedUser.fromMap("my-user", null) + + // then + Assertions.assertThat(namedUser).isNull() + } + + @Test + fun `#findUserTokenForCluster is handling users is null`() { + // given + val kubeConfig = mockk() + every { kubeConfig.contexts } returns arrayListOf( + mapOf( + "name" to "skywalker-context", + "context" to mapOf( + "cluster" to "skywalker-cluster", + "user" to "skywalker" + ) + ) + ) + every { kubeConfig.users } returns null + + val token = KubeConfigNamedUser.getUserTokenForCluster("skywalker-cluster", kubeConfig) + + // then + Assertions.assertThat(token).isNull() + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeconfigMonitorTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeconfigMonitorTest.kt new file mode 100644 index 00000000..3c317539 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeconfigMonitorTest.kt @@ -0,0 +1,141 @@ + + +/* + * Copyright (c) 2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.kubeconfig + +import com.redhat.devtools.gateway.openshift.Cluster +import io.mockk.Ordering +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.io.path.createFile + +@ExperimentalCoroutinesApi +class KubeconfigMonitorTest { + + @TempDir + lateinit var tempDir: Path + + private lateinit var testScope: TestScope + private lateinit var mockFileWatcher: FileWatcher + private lateinit var mockKubeConfigBuilder: KubeConfigUtils + private lateinit var kubeconfigMonitor: KubeconfigMonitor + + private lateinit var kubeconfigPath1: Path + private lateinit var kubeconfigPath2: Path + + @BeforeEach + fun beforeEach() { + // given + testScope = TestScope(StandardTestDispatcher()) + mockFileWatcher = mockk(relaxed = true) + mockKubeConfigBuilder = mockk(relaxed = true) + + kubeconfigPath1 = tempDir.resolve("kubeconfig1.yaml").createFile() + kubeconfigPath2 = tempDir.resolve("kubeconfig2.yaml").createFile() + + every { mockFileWatcher.start() } answers { + // Simulate the watcher calling the monitor's onFileChanged when a file changes + } + + kubeconfigMonitor = KubeconfigMonitor(testScope, mockFileWatcher, mockKubeConfigBuilder) + } + + @AfterEach + fun afterEach() { + kubeconfigMonitor.stop() + } + + @Test + fun `#start should initially parse and publish clusters`() = testScope.runTest { + // given + val cluster1 = Cluster("id1", "skywalker", "url1", null) + every { mockKubeConfigBuilder.getAllConfigs() } returns listOf(kubeconfigPath1) + every { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1) + + // when + kubeconfigMonitor.start() + advanceUntilIdle() + + // then + assertThat(kubeconfigMonitor.getCurrentClusters()).containsExactly(cluster1) + verify(exactly = 1) { mockFileWatcher.addFile(kubeconfigPath1) } + verify(exactly = 1) { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath1)) } + } + + @Test + fun `#onFileChanged should reparse and publish updated clusters`() = testScope.runTest { + // given + val cluster1 = Cluster("id1", "skywalker", "url1", null) + val cluster1Updated = Cluster("id1", "skywalker", "url1", "token1") + + every { mockKubeConfigBuilder.getAllConfigs() } returns listOf(kubeconfigPath1) + every { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1) + + kubeconfigMonitor.start() + advanceUntilIdle() + assertThat(kubeconfigMonitor.getCurrentClusters()).containsExactly(cluster1) + + // when + every { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1Updated) + kubeconfigMonitor.onFileChanged(kubeconfigPath1) // Simulate watcher callback + advanceUntilIdle() + + // then + assertThat(kubeconfigMonitor.getCurrentClusters()).containsExactly(cluster1Updated) + verify(exactly = 2) { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath1)) } // Initial + update + } + + @Test + fun `#updateMonitoredPaths should add and remove files based on KUBECONFIG env var`() = testScope.runTest { + // given + val cluster1 = Cluster("id1", "skywalker", "url1", null) + val cluster2 = Cluster("id2", "obi-wan", "url2", null) + + // Initial KUBECONFIG + every { mockKubeConfigBuilder.getAllConfigs() } returns listOf(kubeconfigPath1) + every { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1) + kubeconfigMonitor.start() + advanceUntilIdle() + assertThat(kubeconfigMonitor.getCurrentClusters()).containsExactly(cluster1) + verify(exactly = 1) { mockFileWatcher.addFile(kubeconfigPath1) } + + // when: Change KUBECONFIG to include kubeconfigPath2 and remove kubeconfigPath1 + every { mockKubeConfigBuilder.getAllConfigs() } returns listOf(kubeconfigPath2) + every { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath2)) } returns listOf(cluster2) + + // Manually trigger updateMonitoredPaths and reparse + kubeconfigMonitor.updateMonitoredPaths() + kubeconfigMonitor.refreshClusters() + advanceUntilIdle() + + // then + assertThat(kubeconfigMonitor.getCurrentClusters()).containsExactly(cluster2) + verify(exactly = 1) { + mockFileWatcher.removeFile(kubeconfigPath1) + mockFileWatcher.addFile(kubeconfigPath2) + } + verify(exactly = 1) { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath2)) } + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/redhat/devtools/gateway/openshift/PodsTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/openshift/PodsTest.kt index a264c842..634e774f 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/openshift/PodsTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/openshift/PodsTest.kt @@ -51,7 +51,7 @@ class PodsTest { private var localPort = 0 @BeforeEach - fun setUp() { + fun beforeEach() { client = mockk(relaxed = true) pods = Pods(client) localPort = findFreePort() @@ -60,7 +60,7 @@ class PodsTest { } @AfterEach - fun tearDown() { + fun afterEach() { unmockkConstructor(PortForward::class) } diff --git a/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt index 5ff03202..a4593fae 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt @@ -31,7 +31,7 @@ class RemoteIDEServerTest { private lateinit var remoteIDEServer: RemoteIDEServer @BeforeEach - fun setUp() { + fun beforeEach() { devSpacesContext = mockk(relaxed = true) mockkConstructor(Pods::class) @@ -60,7 +60,7 @@ class RemoteIDEServerTest { } @AfterEach - fun tearDown() { + fun afterEach() { unmockkAll() } diff --git a/src/test/kotlin/com/redhat/devtools/gateway/view/ui/FilteringComboBoxTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/view/ui/FilteringComboBoxTest.kt new file mode 100644 index 00000000..3feab049 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/view/ui/FilteringComboBoxTest.kt @@ -0,0 +1,404 @@ +/* + * Copyright (c) 2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.ui + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.BeforeEach +import java.awt.event.KeyEvent +import javax.swing.* + +class FilteringComboBoxTest { + + private data class TestItem(val id: Int, val name: String) + + private lateinit var comboBox: JComboBox + private var selectedItem: TestItem? = null + private val items = listOf( + TestItem(1, "Luke Skywalker"), + TestItem(2, "Darth Vader"), + TestItem(3, "Princess Leia"), + TestItem(4, "Han Solo"), + TestItem(5, "Chewbacca"), + TestItem(6, "Obi-Wan Kenobi"), + TestItem(7, "Yoda"), + TestItem(8, "Anakin Skywalker") + ) + + @BeforeEach + fun setUp() { + selectedItem = null + } + + @Test + fun `Should use custom toString function for rendering`() { + // given + val customToString: (TestItem?) -> String = { it?.let { "ID: ${it.id} - ${it.name}" } ?: "" } + comboBox = createComboBox(customToString) + comboBox.addItem(items[0]) + + // when + val renderer = comboBox.renderer + val component = renderer.getListCellRendererComponent( + JList(), + items[0], + 0, + false, + false + ) + + // then + assertThat(component).isInstanceOf(JLabel::class.java) + assertThat((component as JLabel).text).isEqualTo("ID: 1 - Luke Skywalker") + } + + @Test + fun `Should invoke given onItemSelected callback when item is selected`() { + // given + comboBox = createComboBox() + items.forEach { comboBox.addItem(it) } + + // when + SwingUtilities.invokeAndWait { + comboBox.selectedIndex = 2 + } + Thread.sleep(100) // Allow time for event processing + + // then + assertThat(selectedItem).isEqualTo(items[2]) + } + + @Test + fun `Should filter items case-insensitively`() { + // given + comboBox = createComboBox() + items.forEach { comboBox.addItem(it) } + val editor = comboBox.editor.editorComponent as JTextField + + // when - simulate typing "sky" (should match "Luke Skywalker" and "Anakin Skywalker") + typeText(editor, "sky") + + // then - model should be filtered + SwingUtilities.invokeAndWait { + assertThat(comboBox.itemCount).isLessThanOrEqualTo(items.size) + // Skywalker names should be in the filtered results + val hasSkywalker = (0 until comboBox.itemCount).any { + comboBox.getItemAt(it)?.name?.contains("Skywalker", ignoreCase = true) == true + } + assertThat(hasSkywalker).isTrue + } + } + + @Test + fun `Should filter items with substring matching`() { + // given + comboBox = createComboBox() + items.forEach { comboBox.addItem(it) } + val editor = comboBox.editor.editorComponent as JTextField + + // when - simulate typing "vader" (should match "Darth Vader") + typeText(editor, "vader") + + // then + SwingUtilities.invokeAndWait { + val filteredItems = (0 until comboBox.itemCount).map { comboBox.getItemAt(it) } + val matchingItems = filteredItems.filter { + it?.name?.contains("vader", ignoreCase = true) == true + } + assertThat(matchingItems.size).isGreaterThanOrEqualTo(1) + } + } + + @Test + fun `Should doe not filter on navigation keys`() { + // given + comboBox = createComboBox() + items.forEach { comboBox.addItem(it) } + val editor = comboBox.editor.editorComponent as JTextField + val initialCount = comboBox.itemCount + + // when - simulate pressing arrow key + pressKey(editor, KeyEvent.VK_DOWN) + + // then - should not trigger filtering + assertThat(comboBox.itemCount).isEqualTo(initialCount) + } + + @Test + fun `Should not filter on escape key`() { + // given + comboBox = createComboBox() + items.forEach { comboBox.addItem(it) } + val editor = comboBox.editor.editorComponent as JTextField + val initialCount = comboBox.itemCount + + // when - simulate pressing escape + pressKey(editor, KeyEvent.VK_ESCAPE) + + // then - should not trigger filtering + assertThat(comboBox.itemCount).isEqualTo(initialCount) + } + + @Test + fun `Should is matching item using custom matchItem function`() { + // given + val matchItem: (String, List) -> TestItem? = { text, items -> + items.find { it.id.toString() == text } + } + comboBox = createComboBox(matchItem = matchItem) + items.forEach { comboBox.addItem(it) } + val editor = comboBox.editor.editorComponent as JTextField + + // when - set text to an ID + SwingUtilities.invokeAndWait { + editor.text = "3" + } + + // then - getItem should return the matched item + val item = comboBox.editor.item + assertThat(item).isInstanceOf(TestItem::class.java) + if (item is TestItem) { + assertThat(item.id).isEqualTo(3) + } + } + + @Test + fun `Should return text when matchItem is null and no exact match`() { + // given + comboBox = createComboBox() + items.forEach { comboBox.addItem(it) } + val editor = comboBox.editor.editorComponent as JTextField + + // when + SwingUtilities.invokeAndWait { + editor.text = "Unknown Item" + } + + // then - should return the text itself + val item = comboBox.editor.item + assertThat(item).isEqualTo("Unknown Item") + } + + @Test + fun `Should set editor text when item is selected`() { + // given + comboBox = createComboBox() + items.forEach { comboBox.addItem(it) } + val editor = comboBox.editor.editorComponent as JTextField + + // when + SwingUtilities.invokeAndWait { + comboBox.selectedItem = items[4] + } + Thread.sleep(100) + + // then + SwingUtilities.invokeAndWait { + assertThat(editor.text).isEqualTo("Chewbacca") + } + } + + @Test + fun `Should handle null items gracefully`() { + // given + comboBox = createComboBox(toString = { it?.name ?: "null" }) + + // when + SwingUtilities.invokeAndWait { + comboBox.addItem(items[0]) + comboBox.addItem(null) + comboBox.addItem(items[1]) + } + + // then + assertThat(comboBox.itemCount).isEqualTo(3) + assertThat(comboBox.getItemAt(1)).isNull() + } + + @Test + fun `Should render null items with toString function`() { + // given + comboBox = createComboBox(toString = { it?.name ?: "" }) + + // when + val renderer = comboBox.renderer + val component = renderer.getListCellRendererComponent( + JList(), + null, + 0, + false, + false + ) + + // then + assertThat(component).isInstanceOf(JLabel::class.java) + // The label should handle null gracefully (may not have text for null) + assertThat((component as JLabel).text).isNotEqualTo("") // null renders as empty, not via toString + } + + @Test + fun `Should hide popup when filtering results in no matches`() { + // given + comboBox = createComboBox() + items.forEach { comboBox.addItem(it) } + val editor = comboBox.editor.editorComponent as JTextField + + // First type something that matches to show popup + typeText(editor, "Luke") + Thread.sleep(200) + + // when - type something that doesn't match any item + SwingUtilities.invokeAndWait { + editor.text = "XYZ123" + } + typeText(editor, "XYZ123") + Thread.sleep(300) // Extra time for filtering to complete + + // then - popup should be hidden or model should be empty/minimal + SwingUtilities.invokeAndWait { + // After filtering with non-matching text, either: + // 1. The popup is hidden (most likely) + // 2. The model is empty or has very few items + val isPopupHidden = !comboBox.isPopupVisible + val hasMinimalItems = comboBox.itemCount <= 1 + assertThat(isPopupHidden || hasMinimalItems).isTrue + } + } + + @Test + fun `Should preserve editor text when filtering`() { + // given + comboBox = createComboBox() + items.forEach { comboBox.addItem(it) } + val editor = comboBox.editor.editorComponent as JTextField + + // when + typeText(editor, "leia") + + // then - editor text should still be "leia" + SwingUtilities.invokeAndWait { + assertThat(editor.text).isEqualTo("leia") + } + } + + @Test + fun `Should not invoke onItemSelected for DESELECTED events`() { + // given + var updateCount = 0 + comboBox = FilteringComboBox.create( + toString = { it?.name ?: "" }, + type = TestItem::class.java, + onItemSelected = { + updateCount++ + selectedItem = it + } + ) + items.forEach { comboBox.addItem(it) } + + // when - select and then deselect + SwingUtilities.invokeAndWait { + comboBox.selectedIndex = 1 + } + Thread.sleep(100) + val firstCount = updateCount + + SwingUtilities.invokeAndWait { + comboBox.selectedIndex = -1 // deselect + } + Thread.sleep(100) + + // then - count should not increase for deselection + assertThat(updateCount).isEqualTo(firstCount) + } + + @Test + fun `Should sort filtered items by match position`() { + // given + comboBox = createComboBox() + // Add items where "an" appears at different positions + listOf( + TestItem(1, "Han Solo"), // 'an' at position 1 + TestItem(2, "Anakin Skywalker"), // 'an' at position 0 + TestItem(3, "Obi-Wan Kenobi"), // 'an' at position 6 + TestItem(4, "Princess Leia") // no 'an' + ).forEach { comboBox.addItem(it) } + val editor = comboBox.editor.editorComponent as JTextField + + // when - filter by "an" + typeText(editor, "an") + Thread.sleep(200) // Extra time for filtering and sorting + + // then - items starting with 'an' should come first + SwingUtilities.invokeAndWait { + assertThat(comboBox.itemCount).isGreaterThan(0) + val firstItem = comboBox.getItemAt(0) + assertThat(firstItem?.name?.contains("an", ignoreCase = true)).isTrue + } + } + + private fun createComboBox( + toString: (TestItem?) -> String = { it?.name ?: "" }, + matchItem: ((String, List) -> TestItem?)? = null + ): JComboBox { + return FilteringComboBox.create( + toString = toString, + matchItem = matchItem, + type = TestItem::class.java, + onItemSelected = { selectedItem = it } + ) + } + + private fun typeText(editor: JTextField, text: String) { + val lastChar = text.lastOrNull() ?: return + val keyCode = when (lastChar.uppercaseChar()) { + 'A' -> KeyEvent.VK_A + 'B' -> KeyEvent.VK_B + 'E' -> KeyEvent.VK_E + 'I' -> KeyEvent.VK_I + 'N' -> KeyEvent.VK_N + 'R' -> KeyEvent.VK_R + 'Y' -> KeyEvent.VK_Y + else -> KeyEvent.VK_UNDEFINED + } + + SwingUtilities.invokeAndWait { + editor.text = text + val keyEvent = KeyEvent( + editor, + KeyEvent.KEY_RELEASED, + System.currentTimeMillis(), + 0, + keyCode, + lastChar + ) + editor.dispatchEvent(keyEvent) + } + + Thread.sleep(100) // Allow event processing + } + + private fun pressKey(editor: JTextField, keyCode: Int, keyChar: Char = KeyEvent.CHAR_UNDEFINED) { + SwingUtilities.invokeAndWait { + val keyEvent = KeyEvent( + editor, + KeyEvent.KEY_RELEASED, + System.currentTimeMillis(), + 0, + keyCode, + keyChar + ) + editor.dispatchEvent(keyEvent) + } + Thread.sleep(50) // Allow time for event processing + } +} + diff --git a/src/test/resources/io.mockk.settings.properties b/src/test/resources/io.mockk.settings.properties new file mode 100644 index 00000000..86803eef --- /dev/null +++ b/src/test/resources/io.mockk.settings.properties @@ -0,0 +1 @@ +mockk.inline=false \ No newline at end of file