diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/BlockStyleFilePersister.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/BlockStyleFilePersister.kt new file mode 100644 index 00000000..33e36beb --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/BlockStyleFilePersister.kt @@ -0,0 +1,50 @@ +/* + * 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.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator +import io.kubernetes.client.persister.ConfigPersister +import java.io.File + +class BlockStyleFilePersister(private val file: File) : ConfigPersister { + + @Throws(java.io.IOException::class) + override fun save( + contexts: ArrayList, + clusters: ArrayList, + users: ArrayList, + preferences: Any?, + currentContext: String? + ) { + val config = mapOf( + "apiVersion" to "v1", + "kind" to "Config", + "current-context" to currentContext, + "preferences" to preferences, + + "clusters" to clusters, + "contexts" to contexts, + "users" to users, + ) + + synchronized(file) { + val yamlFactory = YAMLFactory().apply { + configure(YAMLGenerator.Feature.MINIMIZE_QUOTES, true) + configure(YAMLGenerator.Feature.INDENT_ARRAYS_WITH_INDICATOR, true) + } + val mapper = ObjectMapper(yamlFactory) + mapper.writeValue(file, config) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcher.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcher.kt index 41c835b2..71243e60 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcher.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcher.kt @@ -18,7 +18,7 @@ import kotlin.io.path.exists import kotlin.io.path.isRegularFile class FileWatcher( - private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), private val dispatcher: CoroutineDispatcher = Dispatchers.IO, private val watchService: WatchService = FileSystems.getDefault().newWatchService() ) { @@ -29,15 +29,19 @@ class FileWatcher( 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 + try { + 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() } - val dir = registeredKeys[key] ?: continue - pollEvents(key, dir) - key.reset() + } catch (e: ClosedWatchServiceException) { + // Watch service was closed, exit gracefully } } } @@ -45,7 +49,11 @@ class FileWatcher( fun stop() { watchJob?.cancel() watchJob = null - watchService.close() + try { + watchService.close() + } catch (e: Exception) { + // Ignore exceptions when closing + } } fun addFile(path: Path): FileWatcher { diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt index 3fd0c68a..3a1af7d7 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt @@ -11,35 +11,42 @@ */ package com.redhat.devtools.gateway.kubeconfig +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.sanitizeName +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.urlToName 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 + val cluster: KubeConfigCluster, + val name: String = toName(cluster) ) { + companion object { - fun fromMap(name: String, clusterObject: Any?): KubeConfigNamedCluster? { - val clusterMap = clusterObject as? Map<*, *> ?: return null - val clusterDetails = clusterMap["cluster"] as? Map<*, *> ?: return null - + fun fromMap(map: Map<*,*>): KubeConfigNamedCluster? { + val name = map["name"] as? String ?: return null + val clusterDetails = map["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() + + private fun toName(cluster: KubeConfigCluster): String { + val url = cluster.server + return urlToName(url) ?: url } + + } + + fun toMap(): MutableMap { + return mutableMapOf( + "name" to name, + "cluster" to cluster.toMap() + ) } } @@ -58,48 +65,88 @@ data class KubeConfigCluster( ) } } + + fun toMap(): MutableMap { + val map = mutableMapOf() + map["server"] = server + certificateAuthorityData?.let { map["certificate-authority-data"] = it } + insecureSkipTlsVerify?.let { map["insecure-skip-tls-verify"] = it } + return map + } } data class KubeConfigNamedContext( - val name: String, - val context: KubeConfigContext + val context: KubeConfigContext, + val name: String = toName(context.user, context.cluster) ) { 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 toName(user: String, cluster: String): String { + val sanitizedUser = sanitizeName(user) + val sanitizedCluster = sanitizeName(cluster) + + return when { + sanitizedUser.isEmpty() && sanitizedCluster.isEmpty() -> "" + sanitizedUser.isEmpty() -> sanitizedCluster + sanitizedCluster.isEmpty() -> sanitizedUser + else -> "$sanitizedUser/$sanitizedCluster" } } - private fun getByName(name: String, contextObject: Any?): KubeConfigNamedContext? { - val contextMap = contextObject as? Map<*, *> ?: return null - val contextDetails = contextMap["context"] as? Map<*, *> ?: return null + fun getByClusterName(name: String?, allConfigs: List): KubeConfigNamedContext? { + return allConfigs + .flatMap { + it.contexts ?: emptyList() + } + .mapNotNull { + fromMap(it as? Map<*,*>) + } + .firstOrNull { context -> + name == context.context.cluster + } + } + + fun getByName(clusterName: String, kubeConfig: KubeConfig): KubeConfigNamedContext? { + return (kubeConfig.contexts as? List<*>) + ?.firstNotNullOfOrNull { contextObject -> + val contextEntry = fromMap(contextObject as? Map<*, *>) + if (contextEntry?.context?.cluster == clusterName) { + contextEntry + } else { + null + } + } + } + fun fromMap(map: Map<*, *>?): KubeConfigNamedContext? { + if (map == null) return null + val name = map["name"] as? String ?: return null + val context = map["context"] as? Map<*, *> ?: return null return KubeConfigNamedContext( name = name, - context = KubeConfigContext.fromMap(contextDetails) ?: return null + context = KubeConfigContext.fromMap(context) ?: return null ) } } + + fun toMap(): MutableMap { + return mutableMapOf( + "name" to name, + "context" to context.toMap() + ) + } } data class KubeConfigContext( - val cluster: String, val user: String, + val cluster: 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, @@ -107,41 +154,68 @@ data class KubeConfigContext( ) } } + + fun toMap(): MutableMap { + val map = mutableMapOf() + map["cluster"] = cluster + map["user"] = user + namespace?.let { map["namespace"] = it } + return map + } } data class KubeConfigNamedUser( - val name: String, - val user: KubeConfigUser + val user: KubeConfigUser?, + val name: String ) { companion object { - fun fromMap(name: String, userObject: Any?): KubeConfigNamedUser? { - val userMap = userObject as? Map<*, *> ?: return null - val userDetails = userMap["user"] as? Map<*, *> ?: return null - + + fun getByName(userName: String, config: KubeConfig?): KubeConfigNamedUser? { + return (config?.users ?: emptyList()) + .mapNotNull { + it as? Map + } + .firstOrNull { user -> + userName == user["name"] + } + ?.let { fromMap(it) } + } + + fun fromMap(map: Map<*,*>): KubeConfigNamedUser? { + val name = map["name"] as? String ?: return null + val userDetails = map["user"] as? Map<*, *> ?: return null + val user = KubeConfigUser.fromMap(userDetails) return KubeConfigNamedUser( name = name, - user = KubeConfigUser.fromMap(userDetails) + user = user ) } - + fun getUserTokenForCluster(clusterName: String, kubeConfig: KubeConfig): String? { - val contextEntry = KubeConfigNamedContext.getByClusterName(clusterName, kubeConfig) ?: return null + val contextEntry = KubeConfigNamedContext.getByName(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 + } as? Map<*,*> ?: return null + return fromMap(userObject)?.user?.token } fun isTokenAuth(kubeConfig: KubeConfig): Boolean { return kubeConfig.credentials?.containsKey(KubeConfig.CRED_TOKEN_KEY) == true } } + + fun toMap(): MutableMap { + return mutableMapOf( + "name" to name, + "user" to (user?.toMap() ?: mutableMapOf()) + ) + } } data class KubeConfigUser( - val token: String? = null, + var token: String? = null, val clientCertificateData: String? = null, val clientKeyData: String? = null, val username: String? = null, @@ -158,5 +232,17 @@ data class KubeConfigUser( ) } } + + fun toMap(): MutableMap { + val map = mutableMapOf() + token?.let { map["token"] = it } + clientCertificateData?.let { map["client-certificate-data"] = it } + clientKeyData?.let { map["client-key-data"] = it } + username?.let { map["username"] = it } + password?.let { map["password"] = it } + return map + } } + + diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitor.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitor.kt index 3bb93550..434ca530 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitor.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitor.kt @@ -65,7 +65,7 @@ class KubeConfigMonitor( internal fun updateMonitoredPaths() { val newPaths = mutableSetOf() - newPaths.addAll(kubeConfigUtils.getAllConfigs()) + newPaths.addAll(kubeConfigUtils.getAllConfigFiles()) stopWatchingRemoved(newPaths) startWatchingNew(newPaths) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt new file mode 100644 index 00000000..025931a5 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt @@ -0,0 +1,198 @@ +/* + * 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.intellij.util.text.UniqueNameGenerator +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.path +import com.redhat.devtools.gateway.openshift.Utils +import io.kubernetes.client.util.KubeConfig +import java.nio.file.Path + +abstract class KubeConfigUpdate private constructor( + protected val clusterName: String, + protected val clusterUrl: String, + protected val token: String, + protected val allConfigs: List +) { + + companion object { + fun create(clusterName: String, clusterUrl: String, token: String): KubeConfigUpdate { + val allConfigs = KubeConfigUtils.getAllConfigs(KubeConfigUtils.getAllConfigFiles()) + val context = KubeConfigNamedContext.getByClusterName(clusterName, allConfigs) + return if (context == null) { + CreateContext(clusterName, clusterUrl, token, allConfigs) + } else { + UpdateToken(clusterName, clusterUrl, token, context, allConfigs) + } + } + } + + abstract fun apply() + + protected fun save( + contexts: ArrayList, + clusters: ArrayList, + users: ArrayList, + preferences: Any, + currentContext: String?, + path: Path? + ) { + val file = path?.toFile() ?: run { + thisLogger().info("Could not write kubeconfig file. Path missing.") + return + } + val persister = BlockStyleFilePersister(file) + persister.save( + contexts, + clusters, + users, + preferences, + currentContext + ) + } + + class UpdateToken( + clusterName: String, + clusterUrl: String, + token: String, + private val context: KubeConfigNamedContext, + allConfigs: List + ) : KubeConfigUpdate(clusterName, clusterUrl, token, allConfigs) { + + override fun apply() { + updateToken(context.context.user) + updateCurrentContext(context.name) + } + + private fun updateToken(username: String) { + val config = KubeConfigUtils.getConfigByUser(context, allConfigs) ?: return + config.users?.find { user -> + username == Utils.getValue(user, arrayOf("name")) + }?.apply { + Utils.setValue(this, token, arrayOf("user", "token")) + } + + save( + config.contexts, + config.clusters, + config.users, + config.preferences, + config.currentContext, + config.path + ) + } + + private fun updateCurrentContext(contextName: String) { + val config = KubeConfigUtils.getConfigWithCurrentContext(allConfigs) ?: return + save( + config.contexts, + config.clusters, + config.users, + config.preferences, + contextName, + config.path + ) + } + } + + class CreateContext( + clusterName: String, + clusterUrl: String, + token: String, + allConfigs: List + ) : KubeConfigUpdate(clusterName, clusterUrl, token, allConfigs) { + override fun apply() { + // create new context in first config + val config = allConfigs.firstOrNull() ?: return + + val user = createUser(allConfigs) + val users = config.users ?: ArrayList() + users.add(user.toMap()) + + val cluster = createCluster(allConfigs) + val clusters = config.clusters ?: ArrayList() + clusters.add(cluster.toMap()) + + val context = createContext(user, cluster, allConfigs) + val contexts = config.contexts ?: ArrayList() + contexts.add(context.toMap()) + + config.setContext(context.name) + + save( + contexts, + clusters, + users, + config.preferences, + config.currentContext, + config.path + ) + } + + private fun createUser(allConfigs: List): KubeConfigNamedUser { + val existingUserNames = getAllExistingNames(allConfigs) { it.users } + val uniqueUserName = UniqueNameGenerator.generateUniqueName(clusterName, existingUserNames) + return KubeConfigNamedUser( + KubeConfigUser(token), + uniqueUserName + ) + } + + private fun createCluster(allConfigs: List): KubeConfigNamedCluster { + val existingClusterNames = getAllExistingNames(allConfigs) { it.clusters } + val uniqueClusterName = UniqueNameGenerator.generateUniqueName(clusterName, existingClusterNames) + + return KubeConfigNamedCluster( + KubeConfigCluster(clusterUrl), + uniqueClusterName + ) + } + + private fun createContext( + user: KubeConfigNamedUser, + cluster: KubeConfigNamedCluster, + allConfigs: List + ): KubeConfigNamedContext { + val existingContextNames = getAllExistingNames(allConfigs) { it.contexts } + val tempContext = KubeConfigNamedContext( + KubeConfigContext( + user.name, + cluster.name + ) + ) + val uniqueContextName = UniqueNameGenerator.generateUniqueName(tempContext.name, existingContextNames) + + return KubeConfigNamedContext( + KubeConfigContext( + user.name, + cluster.name + ), + uniqueContextName + ) + } + + private fun getAllExistingNames( + allConfigs: List, + extractList: (KubeConfig) -> List<*>? + ): Set { + return allConfigs + .flatMap { config -> extractList(config) ?: emptyList() } + .mapNotNull { entryObject -> + val entryMap = entryObject as? Map<*, *> ?: return@mapNotNull null + entryMap["name"] as? String + } + .toSet() + } + } + +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt index e341d44a..0154afb3 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt @@ -1,11 +1,18 @@ package com.redhat.devtools.gateway.kubeconfig +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory import com.intellij.openapi.diagnostic.thisLogger import com.intellij.util.EnvironmentUtil import com.redhat.devtools.gateway.openshift.Cluster +import com.redhat.devtools.gateway.openshift.Utils +import com.sun.jndi.toolkit.url.Uri import io.kubernetes.client.util.KubeConfig import java.io.File +import java.net.URI import java.nio.file.Path +import java.util.* +import java.util.Locale.getDefault import kotlin.io.path.Path import kotlin.io.path.exists import kotlin.io.path.isRegularFile @@ -15,21 +22,25 @@ object KubeConfigUtils { private val logger = thisLogger() fun isCurrentUserTokenAuth(kubeConfig: KubeConfig): Boolean { - return kubeConfig.credentials.containsKey(KubeConfig.CRED_TOKEN_KEY) + val currentContext = getCurrentContext(kubeConfig) ?: return false + val currentUser = KubeConfigNamedUser.getByName(currentContext.context.user, kubeConfig) + return currentUser?.user?.token != null } fun getClusters(kubeconfigPaths: List): List { logger.info("Getting clusters from kubeconfig paths: $kubeconfigPaths") val kubeConfigs = toKubeConfigs(kubeconfigPaths) - logger.info("Loaded ${kubeConfigs.size} kubeconfig files") + logger.info("Loaded ${kubeConfigs.size} kubeconfig files from paths: $kubeconfigPaths") + 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}") + kubeConfig.clusters?.mapNotNull { cluster -> + val namedCluster = KubeConfigNamedCluster.fromMap(cluster as Map<*, *>) ?: return@mapNotNull null + val token = KubeConfigNamedUser.getUserTokenForCluster(namedCluster.name, kubeConfig) + val cluster = toCluster(namedCluster, token) + logger.debug("Parsed cluster: ${cluster.name} at ${cluster.url}") cluster - } + } ?: emptyList() } .distinctBy { it.id } @@ -37,27 +48,33 @@ object KubeConfigUtils { 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 toKubeConfigs(kubeconfigPaths: List): List { + return 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 content = path.toFile().readText() + if (content.isBlank()) { + logger.info("Kubeconfig file is empty: $path") + return@mapNotNull null + } - private fun toCluster(clusterEntry: KubeConfigNamedCluster, kubeConfig: KubeConfig): Cluster { - val userToken = KubeConfigNamedUser.getUserTokenForCluster(clusterEntry.name, kubeConfig) + val kubeConfig = KubeConfig.loadKubeConfig(content.reader()) + logger.info("loaded kubeconfig from: $path") + kubeConfig + } catch (t: Throwable) { + logger.warn("Error loading kubeconfig file '$path': ${t.message}", t) + null + } + } + } + private fun toCluster(clusterEntry: KubeConfigNamedCluster, userToken: String?): Cluster { return Cluster( url = clusterEntry.cluster.server, name = clusterEntry.name, @@ -65,8 +82,9 @@ object KubeConfigUtils { ) } - private fun getEnvConfigs(): List { - val env = System.getenv("KUBECONFIG") + private fun getEnvConfigs(kubeconfigEnv: String? = null): List { + val env = kubeconfigEnv + ?: System.getenv("KUBECONFIG") ?: EnvironmentUtil.getValue("KUBECONFIG") ?: return emptyList() return env @@ -83,8 +101,8 @@ object KubeConfigUtils { ) } - fun getAllConfigs(): List { - val envPaths = getEnvConfigs() + fun getAllConfigFiles(kubeconfigEnv: String? = null): List { + val envPaths = getEnvConfigs(kubeconfigEnv) return if (envPaths.isNotEmpty()) { envPaths.filter { isValid(it) } } else { @@ -97,34 +115,169 @@ object KubeConfigUtils { && paths.isRegularFile() } - fun getAllConfigsMerged(): String? { - val kubeConfigPaths = getAllConfigs() + private fun getDocuments(file: Path): List { + return try { + file.toFile().readText().split("---") + .map { it.trim() } + .filter { it.isNotEmpty() } + } catch (e: Exception) { + thisLogger().info("Could not read config file: $file", e) + emptyList() + } + } + + private fun getAllDocuments(files: List): List { + return files.flatMap { getDocuments(it) } + } - if (kubeConfigPaths.isEmpty()) { + fun toString(files: List): String? { + if (files.isEmpty()) { logger.debug("No kubeconfig files found.") return null } - - val mergedKubeConfigs = mergeConfigs(kubeConfigPaths) + val mergedKubeConfigs = getAllDocuments(files).joinToString("\n---\n") if (mergedKubeConfigs.isEmpty()) { logger.debug("No valid kubeconfig content found.") return null } - return mergedKubeConfigs } - private fun mergeConfigs(kubeconfigs: List): String { - return kubeconfigs - .mapNotNull { path -> + fun getAllConfigs(files: List): List { + return files.flatMap { file -> + getDocuments(file).mapNotNull { document -> try { - path.toFile().readText() + val kubeConfig = KubeConfig.loadKubeConfig(document.reader()) + kubeConfig?.let { + kubeConfig.path = file + } + kubeConfig } catch (e: Exception) { - logger.warn("Failed to read kubeconfig file '$path': ${e.message}") + thisLogger().info("Could not parse kubeconfig document", e) + null + } + } + } + } + + fun getCurrentContext(kubeConfig: KubeConfig): KubeConfigNamedContext? { + val currentContextName = kubeConfig.currentContext + return (kubeConfig.contexts as? List<*>) + ?.mapNotNull { contextObject -> + val context = KubeConfigNamedContext.fromMap(contextObject as? Map<*,*>) ?: return@mapNotNull null + if (context.name == currentContextName) { + context + } else { + null + } + }?.firstOrNull() + } + + fun getCurrentClusterName(kubeconfigEnv: String? = null): String? { + return try { + getAllConfigFiles(kubeconfigEnv).firstNotNullOfOrNull { path -> + if (!isValid(path)) { + null + } else { + try { + val kubeConfig = KubeConfig.loadKubeConfig(path.toFile().bufferedReader()) + if (!kubeConfig.currentContext.isNullOrBlank()) { + getCurrentContext(kubeConfig)?.context?.cluster + } else { + null + } + } catch (e: Throwable) { + logger.warn( + "Error parsing kubeconfig file '$path' while determining current context: ${e.message}", + e + ) + null + } + } + } + } catch (e: Exception) { + logger.warn("Failed to get current context cluster name from kubeconfig: ${e.message}", e) + null + } + } + + fun getConfigByUser(context: KubeConfigNamedContext, allConfigs: List): KubeConfig? { + val contextUser = context.context.user + return getConfigByUser(contextUser, allConfigs) + } + + private fun getConfigByUser(userName: String, allConfigs: List): KubeConfig? { + return allConfigs + .firstOrNull { config -> + KubeConfigNamedUser.getByName(userName, config) != null + } + } + + fun getConfigWithCurrentContext(allConfigs: List): KubeConfig? { + return allConfigs + .firstOrNull { config -> + !config.currentContext.isNullOrBlank() + } + } + + fun toUriWithHost(url: String?): URI? { + return if (url.isNullOrBlank()) { + null + } else { + try { + val uri = URI.create(url) + if (uri?.host == null) { null + } else { + uri } + } catch (_: Exception) { + null } - .joinToString("\n---\n") + } + } + + fun toName(uri: URI?): String? { + return if (uri == null) { + null + } else if (uri.host == null) { + null + } else { + "${uri.host}${ + if (uri.port != -1) { + "-${uri.port}" + } else "" + }" + } } + fun urlToName(url: String?): String? { + return if (url?.isEmpty() == true) { + null + } else { + toName(toUriWithHost(url)) + } + } + + fun sanitizeName(name: String): String { + // allowed: only alphanumeric, hyphen, period, max 253 chars + return name + .lowercase(getDefault()) + .replace(Regex("[^a-z0-9-.]"), "-") + .replace(Regex("^(-+)(\\.)*|(-+)(\\.)*$"), "") + .take(253) + } + + private val kubeConfigFiles = WeakHashMap() + + var KubeConfig.path: Path? + get() = kubeConfigFiles[this] + set(value) { + if (value != null) { + kubeConfigFiles[this] = value + this.setFile(value.toFile()) + } else { + kubeConfigFiles.remove(this) + } + } } \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt index 3d4ed34c..013bb6bd 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt @@ -11,8 +11,8 @@ */ package com.redhat.devtools.gateway.openshift -import java.net.URI -import java.net.URISyntaxException +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.toName +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.toUriWithHost data class Cluster( val name: String, @@ -21,26 +21,50 @@ data class Cluster( ) { companion object { - fun fromUrl(url: String): Cluster? { - return try { - val name = toName(url) - if (name == null) { - null - } else { - Cluster(name, url) // Use host directly from URI + fun fromNameAndUrl(nameAndUrl: String): Cluster? { + val parsed = getNameAndUrl(nameAndUrl) + val name = parsed?.first + val uri = toUriWithHost(parsed?.second) + return when { + name != null && uri != null -> + Cluster(name, uri.toString()) + uri != null -> { + val nameFromUrl = toName(uri) ?: return null + Cluster(nameFromUrl, uri.toString()) } - } catch(_: URISyntaxException) { - null + else -> null } } - private fun toName(url: String): String? { - return try { - val uri = URI(url) - uri.host - } catch (_: URISyntaxException) { - null + private fun getNameAndUrl(nameAndUrl: String): Pair? { + // Captures: 1: Name, 2: URL in parentheses, 3: Full URL (if no parens) + val regex = Regex("^(.+?)\\s*\\((.*?)\\)$|^(.+)$") + + val matchResult = regex.find(nameAndUrl.trim()) + ?: return Pair(nameAndUrl.trim(), nameAndUrl.trim()) // Should not happen with this regex + + val (name, urlInParens, fullUrl) = matchResult.destructured + + val pair = when { + // "name (url)" OR "name ()" + name.isNotEmpty() -> { + val trimmedName = name.trim() + val trimmedUrl = urlInParens.trim() + if (trimmedUrl.isEmpty()) { + Pair(trimmedName, null) + } else { + Pair(trimmedName, trimmedUrl) + } + } + // "url-only" (Matches the second alternative: ^(.+)$) + fullUrl.isNotEmpty() -> { + val trimmedUrl = fullUrl.trim() + Pair(null, trimmedUrl) + } + + else -> null } + return pair } } 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 27dfb013..063c0552 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt @@ -27,7 +27,7 @@ class OpenShiftClientFactory(private val kubeConfigBuilder: KubeConfigUtils) { private var lastUsedKubeConfig: KubeConfig? = null fun create(): ApiClient { - val mergedConfig = kubeConfigBuilder.getAllConfigsMerged() + val mergedConfig = kubeConfigBuilder.toString(kubeConfigBuilder.getAllConfigFiles()) ?: run { thisLogger().debug("No effective kubeconfig found. Falling back to default ApiClient.") lastUsedKubeConfig = null diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Utils.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Utils.kt index 3d241626..fccf4046 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Utils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Utils.kt @@ -20,10 +20,33 @@ object Utils { var value = obj for (s in path) { - value = (value as Map<*, *>)[s] ?: return null + value = (value as? Map<*, *>)?.get(s) ?: return null } return value } + + @JvmStatic + fun setValue(obj: Any?, value: Any, path: Array) { + if (obj !is MutableMap<*, *>) { + return + } + + var currentMap: MutableMap = obj as MutableMap + for (i in path.indices) { + val key = path[i] + if (i == path.lastIndex) { + currentMap[key] = value + } else { + var nextMap = currentMap[key] as? MutableMap + if (nextMap == null) { + nextMap = mutableMapOf() + currentMap[key] = nextMap + } + currentMap = nextMap + } + } + } + } 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 2222e7eb..5fa99bc9 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) { enableNextButton() }) + steps.add(DevSpacesServerStepView(devSpacesContext, { enableNextButton() }) { nextStep() }) 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 983b0bad..12125449 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 @@ -13,8 +13,10 @@ package com.redhat.devtools.gateway.view.steps import com.intellij.openapi.application.invokeLater import com.intellij.openapi.components.service +import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager +import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.Align import com.intellij.ui.dsl.builder.panel @@ -23,48 +25,53 @@ 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.kubeconfig.KubeConfigUtils +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUpdate 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.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 com.redhat.devtools.gateway.view.ui.getAllElements -import com.redhat.devtools.gateway.view.ui.requestInitialFocus +import com.redhat.devtools.gateway.view.ui.* import kotlinx.coroutines.* import java.awt.event.ItemEvent +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent import javax.swing.JTextField import javax.swing.event.DocumentEvent import javax.swing.event.DocumentListener class DevSpacesServerStepView( private var devSpacesContext: DevSpacesContext, - private val enableNextButton: (() -> Unit)? + private val enableNextButton: (() -> Unit)?, + private val triggerNextAction: (() -> Unit)? = null ) : DevSpacesWizardStep { + private lateinit var allClusters: List + private val settings: ServerSettings = ServerSettings() - private lateinit var kubeconfigScope: CoroutineScope + private lateinit var kubeconfigScope: CoroutineScope private lateinit var kubeconfigMonitor: KubeConfigMonitor + private val updateKubeconfigCheckbox = JBCheckBox("Save configuration") + private var tfToken = JBTextField() .apply { document.addDocumentListener(onTokenChanged()) PasteClipboardMenu.addTo(this) + addKeyListener(createEnterKeyListener()) } - private var tfServer = FilteringComboBox.create( { it?.toString() ?: "" }, - { Cluster.fromUrl(it) } + { Cluster.fromNameAndUrl(it) } ) .apply { addItemListener(::onClusterSelected) PasteClipboardMenu.addTo(this.editor.editorComponent as JTextField) + (this.editor.editorComponent as JTextField).addKeyListener(createEnterKeyListener()) } override val component = panel { @@ -79,6 +86,13 @@ class DevSpacesServerStepView( row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.token")) { cell(tfToken).align(Align.FILL) } + row("") { + cell(updateKubeconfigCheckbox).applyToComponent { + isOpaque = false + background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() + } + enabled(false) + } }.apply { background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() border = JBUI.Borders.empty(8) @@ -96,33 +110,63 @@ class DevSpacesServerStepView( private fun onClusterSelected(event: ItemEvent) { if (event.stateChange == ItemEvent.SELECTED) { (event.item as? Cluster)?.let { selectedCluster -> - val allClusters = tfServer.getAllElements() if (allClusters.contains(selectedCluster)) { tfToken.text = selectedCluster.token + updateKubeconfigCheckbox.isSelected = false } } } + enableKubeconfigCheckbox() } private fun onTokenChanged(): DocumentListener = object : DocumentListener { override fun insertUpdate(event: DocumentEvent) { enableNextButton?.invoke() + enableKubeconfigCheckbox() } override fun removeUpdate(e: DocumentEvent) { enableNextButton?.invoke() + enableKubeconfigCheckbox() } override fun changedUpdate(e: DocumentEvent?) { enableNextButton?.invoke() + enableKubeconfigCheckbox() + } + } + + private fun enableKubeconfigCheckbox() { + val cluster = tfServer.selectedItem as Cluster? + val token = tfToken.text + updateKubeconfigCheckbox.isEnabled = + !allClusters.contains(cluster) + || (cluster?.token ?: "") != token + } + + private fun createEnterKeyListener(): KeyAdapter { + return object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + if (e.keyCode == KeyEvent.VK_ENTER && isNextEnabled()) { + triggerNextAction?.invoke() + } + } } } private fun onClustersChanged(): suspend (List) -> Unit = { updatedClusters -> - invokeLater { - val selectedName = (tfServer.selectedItem as? Cluster)?.name - setClusters(updatedClusters) - setSelectedCluster(selectedName, updatedClusters) + this.allClusters = updatedClusters + if (updatedClusters.isNotEmpty()) { + invokeLater { + val kubeConfigCurrentCluster = KubeConfigUtils.getCurrentClusterName() + val previouslySelected = tfServer.selectedItem as? Cluster? + setClusters(updatedClusters) + setSelectedCluster( + (previouslySelected)?.name ?: kubeConfigCurrentCluster, + updatedClusters + ) + enableKubeconfigCheckbox() + } } } @@ -137,11 +181,15 @@ class DevSpacesServerStepView( val token = tfToken.text val client = OpenShiftClientFactory(KubeConfigUtils).create(server, token.toCharArray()) var success = false + stopKubeconfigMonitor() ProgressManager.getInstance().runProcessWithProgressSynchronously( { try { + val indicator = ProgressManager.getInstance().progressIndicator + saveKubeconfig(tfServer.selectedItem as? Cluster?, tfToken.text, indicator) + indicator.text = "Checking connection..." Projects(client).isAuthenticated() success = true } catch (e: Exception) { @@ -167,6 +215,26 @@ class DevSpacesServerStepView( && tfToken.text.isNotEmpty() } + private fun saveKubeconfig(cluster: Cluster?, token: String?, indicator: ProgressIndicator) { + if (cluster == null + || token.isNullOrBlank() + || !updateKubeconfigCheckbox.isSelected) { + return + } + + try { + indicator.text = "Updating Kube config..." + KubeConfigUpdate + .create( + cluster.name.trim(), + cluster.url.trim(), + token.trim()) + .apply() + } catch (e: Exception) { + Dialogs.error( e.message ?: "Could not update kube config file", "Kubeconfig Update Failed") + } + } + private fun setClusters(clusters: List) { this.tfServer.removeAllItems() clusters.forEach { @@ -185,7 +253,7 @@ class DevSpacesServerStepView( } private fun startKubeconfigMonitor() { - this.kubeconfigScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + this.kubeconfigScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) this.kubeconfigMonitor = KubeConfigMonitor( kubeconfigScope, FileWatcher(kubeconfigScope), diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/ui/SwingUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/ui/SwingUtils.kt index fd75060b..0d50e031 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/ui/SwingUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/ui/SwingUtils.kt @@ -31,11 +31,6 @@ fun JList.onDoubleClick(action: (T) -> Unit) { }) } -fun JComboBox.getAllElements(): List { - return (0 until model.size) - .map { index -> model.getElementAt(index) }.toList() -} - fun JPanel.requestInitialFocus(component: JComboBox) { addAncestorListener(object : AncestorListenerAdapter() { override fun ancestorAdded(event: AncestorEvent?) { diff --git a/src/main/resources/messages/DevSpacesBundle.properties b/src/main/resources/messages/DevSpacesBundle.properties index 7cb2a2a2..9106ff8c 100644 --- a/src/main/resources/messages/DevSpacesBundle.properties +++ b/src/main/resources/messages/DevSpacesBundle.properties @@ -8,6 +8,8 @@ connector.wizard_step.openshift_connection.label.server=Server: connector.wizard_step.openshift_connection.label.token=Token: connector.wizard_step.openshift_connection.button.previous=Back connector.wizard_step.openshift_connection.button.next=Check connection and continue +connector.wizard_step.openshift_connection.label.update_kubeconfig_path=Update kubeconfig: {0} +connector.wizard_step.openshift_connection.label.update_kubeconfig=Update kubeconfig # Wizard selecting DevWorkspace step connector.wizard_step.remote_server_connection.title=Select running DevWorkspace diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/BlockStyleFilePersisterTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/BlockStyleFilePersisterTest.kt new file mode 100644 index 00000000..ce12dcc0 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/BlockStyleFilePersisterTest.kt @@ -0,0 +1,193 @@ +/* + * 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 aavailable 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.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +class BlockStyleFilePersisterTest { + + @TempDir + lateinit var tempDir: Path + + @Test + fun `save should write kubeconfig to file`() { + // given + val file = tempDir.resolve("test-kubeconfig").toFile() + val persister = BlockStyleFilePersister(file) + + val expectedContent = """ + apiVersion: v1 + kind: Config + current-context: death-star-context + preferences: {} + clusters: + - name: death-star + cluster: + server: https://death-star.com + contexts: + - name: death-star-context + context: + cluster: death-star + user: darth-vader + users: + - name: darth-vader + user: + token: join-the-dark-side + """.trimIndent() + + val clusters = arrayListOf( + KubeConfigNamedCluster( + KubeConfigCluster( + server = "https://death-star.com" + ), + name = "death-star" + ).toMap() + ) + val contexts = arrayListOf( + KubeConfigNamedContext( + KubeConfigContext( + user = "darth-vader", + cluster = "death-star" + ), + name = "death-star-context" + ).toMap() + ) + val users = arrayListOf( + KubeConfigNamedUser( + KubeConfigUser( + token = "join-the-dark-side" + ), + name = "darth-vader" + ).toMap() + ) + + // when + persister.save( + contexts, + clusters, + users, + emptyMap(), + "death-star-context" + ) + + // then + val actualContent = file.readText() + assertYamlEquals(expectedContent, actualContent) + } + + @Test + fun `save should handle empty kubeconfig`() { + // given + val file = tempDir.resolve("empty-kubeconfig").toFile() + val persister = BlockStyleFilePersister(file) + + val expectedContent = """ + apiVersion: v1 + kind: Config + current-context: "" + preferences: {} + clusters: [] + contexts: [] + users: [] + """.trimIndent() + + // when + persister.save( + ArrayList(), + ArrayList(), + ArrayList(), + mutableMapOf(), + "" + ) + + // then + val actualContent = file.readText() + assertYamlEquals(expectedContent, actualContent) + } + + @Test + fun `save should handle multiple entries`() { + // given + val file = tempDir.resolve("multiple-entries-kubeconfig").toFile() + val persister = BlockStyleFilePersister(file) + + val expectedContent = """ + apiVersion: v1 + kind: Config + current-context: tatooine-context + preferences: {} + clusters: + - name: tatooine + cluster: + server: https://tatooine.com + - name: dagobah + cluster: + server: https://dagobah.com + contexts: + - name: tatooine-context + context: + cluster: tatooine + user: luke-skywalker + - name: dagobah-context + context: + cluster: dagobah + user: yoda + users: + - name: luke-skywalker + user: + token: use-the-force + - name: yoda + user: + token: do-or-do-not + """.trimIndent() + + val clusters = arrayListOf( + KubeConfigNamedCluster(KubeConfigCluster(server = "https://tatooine.com"), name = "tatooine").toMap(), + KubeConfigNamedCluster(KubeConfigCluster(server = "https://dagobah.com"), name = "dagobah").toMap() + ) + val contexts = arrayListOf( + KubeConfigNamedContext(KubeConfigContext(user = "luke-skywalker", cluster = "tatooine"), name = "tatooine-context").toMap(), + KubeConfigNamedContext(KubeConfigContext(user = "yoda", cluster = "dagobah"), name = "dagobah-context").toMap() + ) + val users = arrayListOf( + KubeConfigNamedUser(KubeConfigUser(token = "use-the-force"), name = "luke-skywalker").toMap(), + KubeConfigNamedUser(KubeConfigUser(token = "do-or-do-not"), name = "yoda").toMap() + ) + + // when + persister.save( + contexts, + clusters, + users, + emptyMap(), + "tatooine-context" + ) + + // then + val actualContent = file.readText() + assertYamlEquals(expectedContent, actualContent) + } + + private fun assertYamlEquals(expected: String, actual: String) { + val mapper = ObjectMapper(YAMLFactory()) + val expectedNode: JsonNode = mapper.readTree(expected) + val actualNode: JsonNode = mapper.readTree(actual) + assertThat(actualNode).isEqualTo(expectedNode) + } + +} \ No newline at end of file diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigClusterTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigClusterTest.kt new file mode 100644 index 00000000..9429b811 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigClusterTest.kt @@ -0,0 +1,145 @@ +/* + * 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 org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +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 + assertThat(cluster).isNotNull + assertThat(cluster?.server).isEqualTo("https://api.example.com:6443") + assertThat(cluster?.certificateAuthorityData).isEqualTo("LS0tLS1CRUdJTi...") + 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 + assertThat(cluster).isNotNull + assertThat(cluster?.server).isEqualTo("https://api.example.com:6443") + assertThat(cluster?.certificateAuthorityData).isNull() + 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 + assertThat(cluster).isNull() + } + + @Test + fun `#fromMap returns null for empty map`() { + // given + // empty map + + // when + val cluster = KubeConfigCluster.fromMap(emptyMap()) + + // then + 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 + 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 + assertThat(cluster).isNotNull + assertThat(cluster?.insecureSkipTlsVerify).isNull() + } + + @Test + fun `#toMap returns map with all fields`() { + // given + val cluster = KubeConfigCluster( + server = "https://tatooine.starwars.galaxy:6443", + certificateAuthorityData = "LS0tLS1CRUdJTi1MSUdIVF..." /* A long time ago in a galaxy far, far away... */, + insecureSkipTlsVerify = true + ) + + // when + val map = cluster.toMap() + + // then + assertThat(map) + .hasSize(3) + .containsEntry("server", "https://tatooine.starwars.galaxy:6443") + .containsEntry("certificate-authority-data", "LS0tLS1CRUdJTi1MSUdIVF...") + .containsEntry("insecure-skip-tls-verify", true) + } + + @Test + fun `#toMap returns map with only server`() { + // given + val cluster = KubeConfigCluster( + server = "https://endor.starwars.galaxy:6443" + ) + + // when + val map = cluster.toMap() + + // then + assertThat(map) + .hasSize(1) + .containsEntry("server", "https://endor.starwars.galaxy:6443") + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigContextTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigContextTest.kt new file mode 100644 index 00000000..db0a0af4 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigContextTest.kt @@ -0,0 +1,171 @@ +/* + * 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 org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +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 + assertThat(context).isNotNull + assertThat(context?.cluster).isEqualTo("my-cluster") + assertThat(context?.user).isEqualTo("my-user") + 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 + assertThat(context).isNotNull + assertThat(context?.cluster).isEqualTo("my-cluster") + assertThat(context?.user).isEqualTo("my-user") + 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 + 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 + 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 + 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 + 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 + assertThat(context).isNotNull + assertThat(context?.cluster).isEqualTo("my-cluster") + assertThat(context?.user).isEqualTo("my-user") + assertThat(context?.namespace).isNull() + } + + @Test + fun `#toMap returns map with all fields`() { + // given + val context = KubeConfigContext( + user = "Yoda", + cluster = "Dagobah-cluster", + namespace = "Jedi-Temple" + ) + + // when + val map = context.toMap() + + // then + assertThat(map) + .hasSize(3) + .containsEntry("cluster", "Dagobah-cluster") + .containsEntry("user", "Yoda") + .containsEntry("namespace", "Jedi-Temple") + } + + @Test + fun `#toMap returns map without namespace`() { + // given + val context = KubeConfigContext( + cluster = "Tatooine-cluster", + user = "Luke-Skywalker" + ) + + // when + val map = context.toMap() + + // then + assertThat(map) + .hasSize(2) + .containsEntry("cluster", "Tatooine-cluster") + .containsEntry("user", "Luke-Skywalker") + assertThat(map).doesNotContainKey("namespace") + } +} \ 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 deleted file mode 100644 index 448506fd..00000000 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntriesTest.kt +++ /dev/null @@ -1,847 +0,0 @@ -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 index 94550dce..62eff46c 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt @@ -1,5 +1,14 @@ - - +/* + * 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 + */ import com.redhat.devtools.gateway.kubeconfig.FileWatcher import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import com.redhat.devtools.gateway.kubeconfig.KubeConfigMonitor @@ -57,7 +66,7 @@ class KubeConfigMonitorTest { fun `#start should initially parse and publish clusters`() = testScope.runTest { // given val cluster1 = Cluster("skywalker", "url1", null) - every { mockKubeConfigBuilder.getAllConfigs() } returns listOf(kubeconfigPath1) + every { mockKubeConfigBuilder.getAllConfigFiles(any()) } returns listOf(kubeconfigPath1) every { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1) // when @@ -76,7 +85,7 @@ class KubeConfigMonitorTest { val cluster1 = Cluster("skywalker", "url1", null) val cluster1Updated = Cluster("skywalker", "url1", "token1") - every { mockKubeConfigBuilder.getAllConfigs() } returns listOf(kubeconfigPath1) + every { mockKubeConfigBuilder.getAllConfigFiles(any()) } returns listOf(kubeconfigPath1) every { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1) kubeconfigMonitor.start() @@ -100,7 +109,7 @@ class KubeConfigMonitorTest { val cluster2 = Cluster("obi-wan", "url2") // Initial KUBECONFIG - every { mockKubeConfigBuilder.getAllConfigs() } returns listOf(kubeconfigPath1) + every { mockKubeConfigBuilder.getAllConfigFiles(any()) } returns listOf(kubeconfigPath1) every { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1) kubeconfigMonitor.start() advanceUntilIdle() @@ -108,7 +117,7 @@ class KubeConfigMonitorTest { verify(exactly = 1) { mockFileWatcher.addFile(kubeconfigPath1) } // when: Change KUBECONFIG to include kubeconfigPath2 and remove kubeconfigPath1 - every { mockKubeConfigBuilder.getAllConfigs() } returns listOf(kubeconfigPath2) + every { mockKubeConfigBuilder.getAllConfigFiles(any()) } returns listOf(kubeconfigPath2) every { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath2)) } returns listOf(cluster2) // Manually trigger updateMonitoredPaths and reparse diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigNamedClusterTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigNamedClusterTest.kt new file mode 100644 index 00000000..b14ca9fc --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigNamedClusterTest.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 io.kubernetes.client.util.KubeConfig +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class KubeConfigNamedClusterTest { + + @Test + fun `#fromMap is parsing named cluster`() { + // given + val clusterObject = mapOf( + "name" to "my-cluster", + "cluster" to mapOf( + "server" to "https://api.example.com:6443", + "certificate-authority-data" to "LS0tLS1CRUdJTi..." + ) + ) + + // when + val namedCluster = KubeConfigNamedCluster.fromMap(clusterObject) + + // then + assertThat(namedCluster).isNotNull + assertThat(namedCluster?.name).isEqualTo("my-cluster") + assertThat(namedCluster?.cluster?.server).isEqualTo("https://api.example.com:6443") + } + + @Test + fun `#fromMap returns null when cluster details are invalid`() { + // given + val clusterObject = mapOf( + "name" to "my-cluster", + "cluster" to mapOf( + "invalid" to "data" + ) + ) + + // when + val namedCluster = KubeConfigNamedCluster.fromMap(clusterObject) + + // then + 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(clusterObject) + + // then + assertThat(namedCluster).isNull() + } + + @Test + fun `#toMap returns map representation`() { + // given + val namedCluster = KubeConfigNamedCluster( + name = "Death-Star-Cluster", + cluster = KubeConfigCluster(server = "https://alderaan.starwars.galaxy") + ) + + // when + val map = namedCluster.toMap() + + // then + assertThat(map) + .hasSize(2) + .containsEntry("name", "Death-Star-Cluster") + val clusterMap = map["cluster"] as? Map + assertThat(clusterMap) + .isNotNull + .containsEntry("server", "https://alderaan.starwars.galaxy") + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigNamedContextTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigNamedContextTest.kt new file mode 100644 index 00000000..8390fb48 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigNamedContextTest.kt @@ -0,0 +1,236 @@ +/* + * 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 io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class KubeConfigNamedContextTest { + + @Test + fun `KubeConfigNamedContext#getByName 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.getByName("skywalker-cluster", kubeConfig) + + // then + assertThat(namedContext).isNotNull + assertThat(namedContext?.name).isEqualTo("skywalker-context") + assertThat(namedContext?.context?.cluster).isEqualTo("skywalker-cluster") + assertThat(namedContext?.context?.user).isEqualTo("skywalker") + } + + @Test + fun `KubeConfigNamedContext#getByName 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" + ) + ) + ) + // when + val namedContext = KubeConfigNamedContext.getByName("nonexistent", kubeConfig) + // then + assertThat(namedContext).isNull() + } + + @Test + fun `KubeConfigNamedContext#getByName returns null when contexts is null`() { + // given + val kubeConfig = mockk() + every { kubeConfig.contexts } returns null + // when + val namedContext = KubeConfigNamedContext.getByName("skywalker", kubeConfig) + // then + assertThat(namedContext).isNull() + } + + @Test + fun `KubeConfigNamedContext#getByName 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" + ) + ) + ) + // when + val namedContext = KubeConfigNamedContext.getByName("darth-vader-cluster", kubeConfig) + // then + assertThat(namedContext).isNotNull + assertThat(namedContext?.name).isEqualTo("darth-vader-context") + } + + @Test + fun `KubeConfigNamedContext#getByName 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" + ) + ) + ) + // when + val namedContext = KubeConfigNamedContext.getByName("skywalker-cluster", kubeConfig) + // then + assertThat(namedContext).isNotNull + assertThat(namedContext?.name).isEqualTo("skywalker-context") + } + + @Test + fun `KubeConfigNamedContext#getByName 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" + ) + ) + ) + // when + val namedContext = KubeConfigNamedContext.getByName("darth-vader-cluster", kubeConfig) + // then + assertThat(namedContext).isNotNull + assertThat(namedContext?.name).isEqualTo("darth-vader-context") + } + + @Test + fun `#toMap returns map representation`() { + // given + val namedContext = KubeConfigNamedContext( + name = "Tatooine-Context", + context = KubeConfigContext(cluster = "Tatooine-cluster", user = "Luke-Skywalker") + ) + + // when + val map = namedContext.toMap() + + // then + assertThat(map) + .hasSize(2) + .containsEntry("name", "Tatooine-Context") + val contextMap = map["context"] as? Map + assertThat(contextMap) + .isNotNull + .containsEntry("cluster", "Tatooine-cluster") + .containsEntry("user", "Luke-Skywalker") + } + + @Test + fun `#name has user and cluster if given context has both not empty`() { + // given + val user = "luke-skywalker" + val cluster = "tatooine-cluster" + + // when + val context = KubeConfigNamedContext( + KubeConfigContext(cluster = cluster, user = user) + ) + + // then + assertThat(context.name).isEqualTo("luke-skywalker/tatooine-cluster") + } + + @Test + fun `#name has no special characters if given context has user and cluster with special characters`() { + // given + val user = "leia@alderaan.gov" + val cluster = "death_star.cluster-complex" + + // when + val context = KubeConfigNamedContext( + KubeConfigContext(user, cluster) + ) + + // then + assertThat(context.name).isEqualTo("leia-alderaan.gov/death-star.cluster-complex") + } + + @Test + fun `#name has only cluster if given context has empty user`() { + // given + val user = "" + val cluster = "tatooine-cluster" + + // when + val context = KubeConfigNamedContext( + KubeConfigContext(user, cluster) + ) + + // then + assertThat(context.name).isEqualTo("tatooine-cluster") + } + + @Test + fun `#name has just user if given context has empty cluster`() { + // given + val user = "luke-skywalker" + val cluster = "" + + // when + val context = KubeConfigNamedContext( + KubeConfigContext(user, cluster) + ) + + // then + assertThat(context.name).isEqualTo("luke-skywalker") + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigNamedUserTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigNamedUserTest.kt new file mode 100644 index 00000000..8b544eca --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigNamedUserTest.kt @@ -0,0 +1,205 @@ +/* + * 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 io.mockk.every +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class KubeConfigNamedUserTest { + + @Test + fun `#fromMap is parsing named user`() { + // given + val userObject = mapOf( + "name" to "DarthVader", + "user" to mapOf( + "token" to "dark-force" + ) + ) + + // when + val namedUser = KubeConfigNamedUser.fromMap(userObject) + + // then + assertThat(namedUser).isNotNull + assertThat(namedUser?.name).isEqualTo("DarthVader") + assertThat(namedUser?.user?.token).isEqualTo("dark-force") + } + + @Test + fun `#fromMap returns null when user key is missing`() { + // given + val userObject = mapOf( + "name" to "my-user" + ) + + // when + val namedUser = KubeConfigNamedUser.fromMap(userObject) + + // then + assertThat(namedUser).isNull() + } + + @Test + fun `#isTokenAuth returns true when current user has token`() { + // given + val kubeConfig = KubeConfigTestHelpers.createMockKubeConfig( + credentials = mapOf(KubeConfig.CRED_TOKEN_KEY to "Help me, Obi-Wan Kenobi") + ) + + // when + val isTokenAuth = KubeConfigNamedUser.isTokenAuth(kubeConfig) + + assertThat(isTokenAuth).isTrue() + } + + @Test + fun `#isTokenAuth returns false when current user has no token`() { + // given + val kubeConfig = KubeConfigTestHelpers.createMockKubeConfig( + credentials = emptyMap() + ) + + // when + val isTokenAuth = KubeConfigNamedUser.isTokenAuth(kubeConfig) + + // then + assertThat(isTokenAuth).isFalse() + } + + @Test + fun `#isTokenAuth returns false when current user is null`() { + val kubeConfig = KubeConfigTestHelpers.createMockKubeConfig() + every { kubeConfig.credentials } returns null + + // when + val isTokenAuth = KubeConfigNamedUser.isTokenAuth(kubeConfig) + + // then + assertThat(isTokenAuth).isFalse() + } + + @Test + fun `#findUserTokenForCluster finds token for cluster`() { + // given + val kubeConfig = KubeConfigTestHelpers.createMockKubeConfig( + contexts = listOf( + KubeConfigTestHelpers.createContextMap("skywalker-context", "skywalker-cluster", "skywalker") + ), + users = listOf( + KubeConfigTestHelpers.createUserMap("skywalker", "secret-token-123") + ) + ) + + // when + val token = KubeConfigNamedUser.getUserTokenForCluster("skywalker-cluster", kubeConfig) + + // then + assertThat(token).isEqualTo("secret-token-123") + } + + @Test + fun `#findUserTokenForCluster returns null when context not found`() { + // given + val kubeConfig = KubeConfigTestHelpers.createMockKubeConfig( + contexts = listOf( + KubeConfigTestHelpers.createContextMap("skywalker-context", "skywalker-cluster", "skywalker") + ) + ) + + // when + val token = KubeConfigNamedUser.getUserTokenForCluster("nonexistent", kubeConfig) + + assertThat(token).isNull() + } + + @Test + fun `#findUserTokenForCluster returns null when user not found`() { + // given + val kubeConfig = KubeConfigTestHelpers.createMockKubeConfig( + contexts = listOf( + KubeConfigTestHelpers.createContextMap("skywalker-context", "skywalker-cluster", "skywalker") + ), + users = listOf( + KubeConfigTestHelpers.createUserMap("different-user", "secret-token-123") + ) + ) + + // when + val token = KubeConfigNamedUser.getUserTokenForCluster("skywalker-cluster", kubeConfig) + + // then + assertThat(token).isNull() + } + + @Test + fun `#findUserTokenForCluster returns null when user has no token`() { + // given + val kubeConfig = KubeConfigTestHelpers.createMockKubeConfig( + contexts = listOf( + KubeConfigTestHelpers.createContextMap("skywalker-context", "skywalker-cluster", "skywalker") + ), + users = listOf( + mapOf( + "name" to "skywalker", + "user" to mapOf("client-certificate-data" to "cert") + ) + ) + ) + + // when + val token = KubeConfigNamedUser.getUserTokenForCluster("skywalker-cluster", kubeConfig) + + // then + assertThat(token).isNull() + } + + @Test + fun `#findUserTokenForCluster is handling users is null`() { + // given + val kubeConfig = KubeConfigTestHelpers.createMockKubeConfig( + contexts = listOf( + KubeConfigTestHelpers.createContextMap("skywalker-context", "skywalker-cluster", "skywalker") + ), + users = null + ) + every { kubeConfig.users } returns null + + val token = KubeConfigNamedUser.getUserTokenForCluster("skywalker-cluster", kubeConfig) + + // then + assertThat(token).isNull() + } + + @Test + fun `#toMap returns map representation`() { + // given + val namedUser = KubeConfigNamedUser( + name = "Han-Solo", + user = KubeConfigUser(token = "Millennium-Falcon-Key") + ) + + // when + val map = namedUser.toMap() + + // then + assertThat(map) + .hasSize(2) + .containsEntry("name", "Han-Solo") + val userMap = map["user"] as? Map + assertThat(userMap) + .isNotNull + .containsEntry("token", "Millennium-Falcon-Key") + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigTestHelpers.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigTestHelpers.kt new file mode 100644 index 00000000..e3044bbf --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigTestHelpers.kt @@ -0,0 +1,115 @@ +/* + * 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.kubeconfig.KubeConfigUtils.path +import io.kubernetes.client.util.KubeConfig +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import java.nio.file.Path + +object KubeConfigTestHelpers { + + /** + * Creates a mock KubeConfig with optional contexts, users, and clusters + */ + fun createMockKubeConfig( + contexts: List>? = null, + users: List>? = null, + clusters: List>? = null, + credentials: Map? = null + ): KubeConfig { + val kubeConfig = mockk(relaxed = true) + contexts?.let { every { kubeConfig.contexts } returns ArrayList(it) } + users?.let { every { kubeConfig.users } returns ArrayList(it) } + clusters?.let { every { kubeConfig.clusters } returns ArrayList(it) } + credentials?.let { every { kubeConfig.credentials } returns it } + return kubeConfig + } + + /** + * Creates a mock KubeConfig with individual maps, path, and optional current context. + * This overload is useful for UpdateToken tests that need path and currentContext. + */ + fun createMockKubeConfig( + path: Path, + userMap: MutableMap, + clusterMap: MutableMap, + contextMap: MutableMap, + currentContext: String? = null + ): KubeConfig { + val config = mockk(relaxed = true) + every { config.contexts } returns ArrayList(listOf(contextMap)) + every { config.clusters } returns ArrayList(listOf(clusterMap)) + every { config.users } returns ArrayList(listOf(userMap)) + every { config.path } returns path + every { config.preferences } returns mockk() + currentContext?.let { every { config.currentContext } returns it } + return config + } + + /** + * Creates a mock KubeConfig with lists of maps, path, and optional setup callback. + * This overload is useful for CreateContext tests that need setupContextCapture. + */ + fun createMockKubeConfig( + path: Path, + contexts: List> = emptyList(), + clusters: List> = emptyList(), + users: List> = emptyList(), + setupContextCapture: ((KubeConfig) -> Unit)? = null + ): KubeConfig { + val config = mockk(relaxed = true) + every { config.contexts } returns ArrayList(contexts) + every { config.clusters } returns ArrayList(clusters) + every { config.users } returns ArrayList(users) + every { config.path } returns path + every { config.preferences } returns mockk() + setupContextCapture?.invoke(config) + return config + } + + /** + * Creates a context map for testing + */ + fun createContextMap(name: String, cluster: String, user: String): MutableMap { + return mutableMapOf( + "name" to name, + "context" to mutableMapOf( + "cluster" to cluster, + "user" to user + ) + ) + } + + /** + * Creates a user map for testing + */ + fun createUserMap(name: String, token: String): MutableMap { + return mutableMapOf( + "name" to name, + "user" to mutableMapOf("token" to token) + ) + } + + /** + * Creates a cluster map for testing + */ + fun createClusterMap(name: String, server: String): MutableMap { + return mutableMapOf( + "name" to name, + "cluster" to mutableMapOf("server" to server) + ) + } +} + diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdateTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdateTest.kt new file mode 100644 index 00000000..d75da0e7 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdateTest.kt @@ -0,0 +1,531 @@ +/* + * 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.Utils +import io.kubernetes.client.util.KubeConfig +import io.mockk.* +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 java.nio.file.Files +import java.nio.file.Path + +class KubeConfigUpdateTest { + + private lateinit var tempKubeConfigFile: Path + + @BeforeEach + fun before() { + mockkObject(KubeConfigUtils) + mockkObject(Utils) + mockkConstructor(BlockStyleFilePersister::class) + every { anyConstructed().save(any(), any(), any(), any(), any()) } returns Unit + this.tempKubeConfigFile = Files.createTempFile("test-kubeconfig", ".tmp") + } + + @AfterEach + fun after() { + unmockkConstructor(BlockStyleFilePersister::class) + unmockkAll() + clearAllMocks() + Files.deleteIfExists(tempKubeConfigFile) + } + + @Test + fun `#apply creates the context if it does not exist`() { + // given + val data = CreateContextTestData() + val config = KubeConfigTestHelpers.createMockKubeConfig(tempKubeConfigFile) + val allConfigs = listOf(config) + setupCreateContextMocks(data.clusterName, allConfigs, tempKubeConfigFile) + + val update = KubeConfigUpdate.CreateContext(data.clusterName, data.clusterUrl, data.token, allConfigs) + + // when + update.apply() + + // then + verify { + anyConstructed().save( + match { contexts -> + assertThat(contexts).hasSize(1) + verifyContext(contexts[0] as Map<*, *>, "${data.clusterName}/${data.clusterName}", data.clusterName, data.clusterName) + }, + match { clusters -> + assertThat(clusters).hasSize(1) + verifyCluster(clusters[0] as Map<*, *>, data.clusterName, data.clusterUrl) + }, + match { users -> + assertThat(users).hasSize(1) + verifyUser(users[0] as Map<*, *>, data.clusterName, data.token) + }, + any(), + any(), + ) + } + } + + @Test + fun `#apply updates the token if context already exists`() { + // given + val data = UpdateTokenTestData() + val (existingUserMap, existingClusterMap, existingContextMap) = createUpdateTokenTestMaps(data) + val config = KubeConfigTestHelpers.createMockKubeConfig(tempKubeConfigFile, existingUserMap, existingClusterMap, existingContextMap) + val allConfigs = listOf(config) + val mockContext = setupUpdateTokenMocks(data, allConfigs, config, null) + + val update = KubeConfigUpdate.UpdateToken(data.clusterName, data.clusterUrl, data.newToken, mockContext, allConfigs) + + // when + update.apply() + + // then + verify { + anyConstructed().save( + match { contexts -> + assertThat(contexts).hasSize(1) + verifyContext(contexts[0] as Map<*, *>, data.clusterName, data.clusterName, data.userName) + }, + match { clusters -> + assertThat(clusters).hasSize(1) + verifyCluster(clusters[0] as Map<*, *>, data.clusterName, data.clusterUrl) + }, + match { users -> + assertThat(users).hasSize(1) + verifyUser(users[0] as Map<*, *>, data.userName, data.newToken) + }, + any(), + any(), + ) + } + } + + @Test + fun `#apply sets current context when updating token`() { + // given + val data = UpdateTokenTestData() + val (existingUserMap, existingClusterMap, existingContextMap) = createUpdateTokenTestMaps(data) + val configForToken = KubeConfigTestHelpers.createMockKubeConfig(tempKubeConfigFile, existingUserMap, existingClusterMap, existingContextMap, "other-context") + val configForCurrentContext = KubeConfigTestHelpers.createMockKubeConfig(tempKubeConfigFile, existingUserMap, existingClusterMap, existingContextMap, "other-context") + val allConfigs = listOf(configForToken, configForCurrentContext) + val mockContext = setupUpdateTokenMocks(data, allConfigs, configForToken, configForCurrentContext) + + val update = KubeConfigUpdate.UpdateToken(data.clusterName, data.clusterUrl, data.newToken, mockContext, allConfigs) + + // when + update.apply() + + // then - verify that save is called twice: once for token update, once for current context update + verify(exactly = 2) { + anyConstructed().save(any(), any(), any(), any(), any()) + } + + // Verify the second call sets the current context + verify { + anyConstructed().save( + any(), + any(), + any(), + any(), + match { currentContext -> verifyCurrentContext(currentContext, data.contextName) }, + ) + } + } + + @Test + fun `#apply does not set current context when no config has current context`() { + // given + val data = UpdateTokenTestData() + val (existingUserMap, existingClusterMap, existingContextMap) = createUpdateTokenTestMaps(data) + val config = KubeConfigTestHelpers.createMockKubeConfig(tempKubeConfigFile, existingUserMap, existingClusterMap, existingContextMap, "") + val allConfigs = listOf(config) + val mockContext = setupUpdateTokenMocks(data, allConfigs, config, null) + + val update = KubeConfigUpdate.UpdateToken(data.clusterName, data.clusterUrl, data.newToken, mockContext, allConfigs) + + // when + update.apply() + + // then - verify that save is called only once (for token update, not for current context) + verify(exactly = 1) { + anyConstructed().save(any(), any(), any(), any(), any()) + } + } + + @Test + fun `#apply generates unique user name if user name already exists`() { + // given + val data = CreateContextTestData() + val existingUserMap = KubeConfigTestHelpers.createUserMap(data.clusterName, "existing-token") + + val config = KubeConfigTestHelpers.createMockKubeConfig( + tempKubeConfigFile, + users = listOf(existingUserMap) + ) + val allConfigs = listOf(config) + setupCreateContextMocks(data.clusterName, allConfigs, tempKubeConfigFile) + + val update = KubeConfigUpdate.CreateContext(data.clusterName, data.clusterUrl, data.token, allConfigs) + + // when + update.apply() + + // then + verify { + anyConstructed().save( + any(), + any(), + match { users -> + verifyNewEntryInList(users, 2, "${data.clusterName}2") { userMap -> + verifyUser(userMap, "${data.clusterName}2", data.token) + } + }, + any(), + any(), + ) + } + } + + @Test + fun `#apply generates unique cluster name if cluster name already exists`() { + // given + val data = CreateContextTestData() + val existingClusterMap = KubeConfigTestHelpers.createClusterMap(data.clusterName, "https://existing.com") + + val config = KubeConfigTestHelpers.createMockKubeConfig( + tempKubeConfigFile, + clusters = listOf(existingClusterMap) + ) + val allConfigs = listOf(config) + setupCreateContextMocks(data.clusterName, allConfigs, tempKubeConfigFile) + + val update = KubeConfigUpdate.CreateContext(data.clusterName, data.clusterUrl, data.token, allConfigs) + + // when + update.apply() + + // then + verify { + anyConstructed().save( + any(), + match { clusters -> + verifyNewEntryInList(clusters, 2, "${data.clusterName}2") { clusterMap -> + verifyCluster(clusterMap, "${data.clusterName}2", data.clusterUrl) + } + }, + any(), + any(), + any(), + ) + } + } + + @Test + fun `#apply generates unique context name if context name already exists`() { + // given + val data = CreateContextTestData() + val existingContextMap = KubeConfigTestHelpers.createContextMap("${data.clusterName}/${data.clusterName}", "other-cluster", "other-user") + + val config = KubeConfigTestHelpers.createMockKubeConfig( + tempKubeConfigFile, + contexts = listOf(existingContextMap) + ) + val allConfigs = listOf(config) + setupCreateContextMocks(data.clusterName, allConfigs, tempKubeConfigFile) + + val update = KubeConfigUpdate.CreateContext(data.clusterName, data.clusterUrl, data.token, allConfigs) + + // when + update.apply() + + // then + verify { + anyConstructed().save( + match { contexts -> + verifyNewEntryInList(contexts, 2, "${data.clusterName}/${data.clusterName}2") { contextMap -> + verifyContext(contextMap, "${data.clusterName}/${data.clusterName}2", data.clusterName, data.clusterName) + } + }, + any(), + any(), + any(), + any(), + ) + } + } + + @Test + fun `#apply generates sequential unique names when multiple entries exist`() { + // given + val data = CreateContextTestData() + // Existing entries: clusterName, clusterName2 + val existingUser1 = KubeConfigTestHelpers.createUserMap(data.clusterName, "token1") + val existingUser2 = KubeConfigTestHelpers.createUserMap("${data.clusterName}2", "token2") + + val config = KubeConfigTestHelpers.createMockKubeConfig( + tempKubeConfigFile, + users = listOf(existingUser1, existingUser2) + ) + val allConfigs = listOf(config) + setupCreateContextMocks(data.clusterName, allConfigs, tempKubeConfigFile) + + val update = KubeConfigUpdate.CreateContext(data.clusterName, data.clusterUrl, data.token, allConfigs) + + // when + update.apply() + + // then + verify { + anyConstructed().save( + any(), + any(), + match { users -> + verifyNewEntryInList(users, 3, "${data.clusterName}3") { userMap -> + verifyUser(userMap, "${data.clusterName}3", data.token) + } + }, + any(), + any(), + ) + } + } + + @Test + fun `#apply checks all configs when generating unique names`() { + // given + val data = CreateContextTestData() + val existingUserMap = KubeConfigTestHelpers.createUserMap(data.clusterName, "existing-token") + + val config1 = KubeConfigTestHelpers.createMockKubeConfig( + tempKubeConfigFile, + users = listOf(existingUserMap) + ) + val config2 = KubeConfigTestHelpers.createMockKubeConfig(tempKubeConfigFile) + val allConfigs = listOf(config1, config2) + setupCreateContextMocks(data.clusterName, allConfigs, tempKubeConfigFile) + + val update = KubeConfigUpdate.CreateContext(data.clusterName, data.clusterUrl, data.token, allConfigs) + + // when + update.apply() + + // then - should generate unique name even though the duplicate is in a different config + verify { + anyConstructed().save( + any(), + any(), + match { users -> + // config1 has 1 existing user, config2 will have 1 new user added + // But we're only saving config1 (first config), so it should have 2 users total + verifyNewEntryInList(users, 2, "${data.clusterName}2") { userMap -> + verifyUser(userMap, "${data.clusterName}2", data.token) + } + }, + any(), + any(), + ) + } + } + + @Test + fun `#apply generates unique names for user cluster and context when all exist`() { + // given + val data = CreateContextTestData() + val existingUserMap = KubeConfigTestHelpers.createUserMap(data.clusterName, "existing-token") + val existingClusterMap = KubeConfigTestHelpers.createClusterMap(data.clusterName, "https://existing.com") + val existingContextMap = KubeConfigTestHelpers.createContextMap("${data.clusterName}/${data.clusterName}", "other-cluster", "other-user") + + val config = KubeConfigTestHelpers.createMockKubeConfig( + tempKubeConfigFile, + contexts = listOf(existingContextMap), + clusters = listOf(existingClusterMap), + users = listOf(existingUserMap) + ) + val allConfigs = listOf(config) + setupCreateContextMocks(data.clusterName, allConfigs, tempKubeConfigFile) + + val update = KubeConfigUpdate.CreateContext(data.clusterName, data.clusterUrl, data.token, allConfigs) + + // when + update.apply() + + // then - all three should have unique names with suffix 2 + verify { + anyConstructed().save( + match { contexts -> + verifyNewEntryInList(contexts, 2, "${data.clusterName}2/${data.clusterName}2") { contextMap -> + verifyContext(contextMap, "${data.clusterName}2/${data.clusterName}2", "${data.clusterName}2", "${data.clusterName}2") + } + }, + match { clusters -> + verifyNewEntryInList(clusters, 2, "${data.clusterName}2") { clusterMap -> + verifyCluster(clusterMap, "${data.clusterName}2", data.clusterUrl) + } + }, + match { users -> + verifyNewEntryInList(users, 2, "${data.clusterName}2") { userMap -> + verifyUser(userMap, "${data.clusterName}2", data.token) + } + }, + any(), + any(), + ) + } + } + + @Test + fun `#apply sets current-context when a new context is created`() { + // given + val data = CreateContextTestData() + val expectedContextName = "${data.clusterName}/${data.clusterName}" + + val config = KubeConfigTestHelpers.createMockKubeConfig( + tempKubeConfigFile, + setupContextCapture = { mockConfig -> + // Capture the context name set via setContext() and return it via currentContext + val contextNameSlot = slot() + every { mockConfig.setContext(capture(contextNameSlot)) } returns true + every { mockConfig.currentContext } answers { + if (contextNameSlot.isCaptured) contextNameSlot.captured else null + } + } + ) + val allConfigs = listOf(config) + setupCreateContextMocks(data.clusterName, allConfigs, tempKubeConfigFile) + + val update = KubeConfigUpdate.CreateContext(data.clusterName, data.clusterUrl, data.token, allConfigs) + + // when + update.apply() + + // then + verify { + anyConstructed().save( + any(), + any(), + any(), + any(), + match { currentContext -> verifyCurrentContext(currentContext, expectedContextName) }, + ) + } + } + + private data class UpdateTokenTestData( + val oldToken: String = "use-the-force", + val newToken: String = "may-the-force-be-with-you", + val clusterName: String = "tatooine", + val clusterUrl: String = "https://tatooine.com", + val userName: String = "luke-skywalker", + val contextName: String = clusterName + ) + + private data class CreateContextTestData( + val clusterName: String = "death-star", + val clusterUrl: String = "https://death-star.com", + val token: String = "join-the-dark-side" + ) + + // Helper functions for test verification and data creation + + private fun findEntryByName(list: List<*>, expectedName: String): Map<*, *>? { + return list.find { entry -> + val entryMap = entry as? Map<*, *> ?: return@find false + val name = Utils.getValue(entryMap, arrayOf("name")) as? String + name == expectedName + } as? Map<*, *> + } + + + private fun verifyContext(context: Map<*, *>, expectedName: String, expectedCluster: String, expectedUser: String): Boolean { + val contextName = Utils.getValue(context, arrayOf("name")) as String + val cluster = Utils.getValue(context, arrayOf("context", "cluster")) as String + val user = Utils.getValue(context, arrayOf("context", "user")) as String + assertThat(contextName).isEqualTo(expectedName) + assertThat(cluster).isEqualTo(expectedCluster) + assertThat(user).isEqualTo(expectedUser) + return true + } + + private fun verifyCluster(cluster: Map<*, *>, expectedName: String, expectedServer: String): Boolean { + val name = Utils.getValue(cluster, arrayOf("name")) as String + val server = Utils.getValue(cluster, arrayOf("cluster", "server")) as String + assertThat(name).isEqualTo(expectedName) + assertThat(server).isEqualTo(expectedServer) + return true + } + + private fun verifyUser(user: Map<*, *>, expectedName: String, expectedToken: String): Boolean { + val name = Utils.getValue(user, arrayOf("name")) as String + val token = Utils.getValue(user, arrayOf("user", "token")) as String + assertThat(name).isEqualTo(expectedName) + assertThat(token).isEqualTo(expectedToken) + return true + } + + private fun verifyCurrentContext(currentContext: String?, expectedContextName: String): Boolean { + assertThat(currentContext).isEqualTo(expectedContextName) + return true + } + + private fun verifyNewEntryInList( + list: List<*>, + expectedSize: Int, + expectedName: String, + verifyEntry: (Map<*, *>) -> Unit + ): Boolean { + assertThat(list).hasSize(expectedSize) + val entry = findEntryByName(list, expectedName) + assertThat(entry).isNotNull() + verifyEntry(entry!!) + return true + } + + private fun createUpdateTokenTestMaps(data: UpdateTokenTestData): Triple, MutableMap, MutableMap> { + val existingUserMap = KubeConfigTestHelpers.createUserMap(data.userName, data.oldToken) + val existingClusterMap = KubeConfigTestHelpers.createClusterMap(data.clusterName, data.clusterUrl) + val existingContextMap = KubeConfigTestHelpers.createContextMap(data.contextName, data.clusterName, data.userName) + return Triple(existingUserMap, existingClusterMap, existingContextMap) + } + + + private fun setupUpdateTokenMocks( + data: UpdateTokenTestData, + allConfigs: List, + configForUser: KubeConfig, + configForCurrentContext: KubeConfig? + ): KubeConfigNamedContext { + mockkObject(KubeConfigNamedContext) + val mockContext = mockk(relaxed = true) + every { mockContext.context } returns KubeConfigContext(data.userName, data.clusterName) + every { mockContext.name } returns data.contextName + every { KubeConfigNamedContext.getByClusterName(data.clusterName, allConfigs) } returns mockContext + every { KubeConfigUtils.getAllConfigs(any()) } returns allConfigs + every { KubeConfigUtils.getAllConfigFiles() } returns listOf(tempKubeConfigFile) + every { KubeConfigUtils.getConfigByUser(mockContext, allConfigs) } returns configForUser + every { KubeConfigUtils.getConfigWithCurrentContext(allConfigs) } returns configForCurrentContext + return mockContext + } + + + private fun setupCreateContextMocks( + clusterName: String, + allConfigs: List, + path: Path + ) { + mockkObject(KubeConfigNamedContext) + every { KubeConfigNamedContext.getByClusterName(clusterName, allConfigs) } returns null + every { KubeConfigUtils.getAllConfigs(any()) } returns allConfigs + every { KubeConfigUtils.getAllConfigFiles() } returns listOf(path) + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUserTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUserTest.kt new file mode 100644 index 00000000..1c349075 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUserTest.kt @@ -0,0 +1,134 @@ +/* + * 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 org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +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 + assertThat(user.token).isEqualTo("my-secret-token") + assertThat(user.clientCertificateData).isNull() + assertThat(user.clientKeyData).isNull() + assertThat(user.username).isNull() + 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 + assertThat(user.token).isEqualTo("my-secret-token") + assertThat(user.clientCertificateData).isEqualTo("cert-data") + assertThat(user.clientKeyData).isEqualTo("key-data") + assertThat(user.username).isEqualTo("admin") + assertThat(user.password).isEqualTo("secret") + } + + @Test + fun `#fromMap returns empty user for empty map`() { + // given + // empty map + + // when + val user = KubeConfigUser.fromMap(emptyMap()) + + assertThat(user.token).isNull() + assertThat(user.clientCertificateData).isNull() + assertThat(user.clientKeyData).isNull() + assertThat(user.username).isNull() + 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 + assertThat(user.token).isNull() + assertThat(user.clientCertificateData).isNull() + assertThat(user.clientKeyData).isNull() + assertThat(user.username).isNull() + assertThat(user.password).isNull() + } + + @Test + fun `#toMap returns map with all fields`() { + // given + val user = KubeConfigUser( + token = "DeathStar-token", + clientCertificateData = "Vader-cert", + clientKeyData = "Vader-key", + username = "DarthVader", + password = "DarkSide" + ) + + // when + val map = user.toMap() + + // then + assertThat(map) + .hasSize(5) + .containsEntry("token", "DeathStar-token") + .containsEntry("client-certificate-data", "Vader-cert") + .containsEntry("client-key-data", "Vader-key") + .containsEntry("username", "DarthVader") + .containsEntry("password", "DarkSide") + } + + @Test + fun `#toMap returns map with only token`() { + // given + val user = KubeConfigUser( + token = "Rebel-Alliance-Token" + ) + + // when + val map = user.toMap() + + // then + assertThat(map) + .hasSize(1) + .containsEntry("token", "Rebel-Alliance-Token") + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtilsTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtilsTest.kt new file mode 100644 index 00000000..7e83af34 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtilsTest.kt @@ -0,0 +1,1037 @@ +/* + * 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.kubeconfig.KubeConfigUtils.path +import com.redhat.devtools.gateway.openshift.Cluster +import io.kubernetes.client.util.KubeConfig +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.createFile +import kotlin.io.path.writeText + + +class KubeConfigUtilsTest { + + @TempDir + lateinit var tempDir: Path + + @Test + fun `#getClusters returns clusters when given multiple kubeconfig files`() { + // given + val kubeConfigFile1 = createTempKubeConfigFile( + "config1", """ + apiVersion: v1 + clusters: + - cluster: + server: https://api.tatooine.starwars.com:6443 + name: tatooine-cluster + contexts: + - context: + cluster: tatooine-cluster + user: luke-skywalker + name: tatooine-cluster + current-context: tatooine-cluster + kind: Config + preferences: {} + users: + - name: luke-skywalker + user: + token: jedi-token + """.trimIndent() + ) + val kubeConfigFile2 = createTempKubeConfigFile( + "config2", """ + apiVersion: v1 + clusters: + - cluster: + server: https://api.dagobah.starwars.com:6443 + name: dagobah-cluster + contexts: + - context: + cluster: dagobah-cluster + user: yoda-master + name: dagobah-cluster + current-context: dagobah-cluster + kind: Config + preferences: {} + users: + - name: yoda-master + user: + token: force-token + """.trimIndent() + ) + + // when + val clusters = KubeConfigUtils.getClusters(listOf(kubeConfigFile1, kubeConfigFile2)) + + // then + assertThat(clusters).containsExactlyInAnyOrder( + Cluster( + name = "tatooine-cluster", + url = "https://api.tatooine.starwars.com:6443", + token = "jedi-token" + ), + Cluster( + name = "dagobah-cluster", + url = "https://api.dagobah.starwars.com:6443", + token = "force-token" + ) + ) + } + + @Test + fun `#getClusters returns no cluster when given non-existent file`() { + // given + val nonExistentFile = tempDir.resolve("non-existent-file") + + // when + val clusters = KubeConfigUtils.getClusters(listOf(nonExistentFile)) + + // then + assertThat(clusters).isEmpty() + } + + @Test + fun `#getClusters returns no clusters when given empty file`() { + // given + val emptyFile = createTempKubeConfigFile("empty-config", "") + + // when + val clusters = KubeConfigUtils.getClusters(listOf(emptyFile)) + + // then + assertThat(clusters).isEmpty() + } + + @Test + fun `#getClusters returns no cluster when given invalid yaml`() { + // given + val invalidFile = createTempKubeConfigFile("invalid-config", "this is not yaml") + + // when + val clusters = KubeConfigUtils.getClusters(listOf(invalidFile)) + + // then + assertThat(clusters).isEmpty() + } + + @Test + fun `#getClusters returns cluster when given one valid and one invalid file`() { + // given + val kubeConfigFile = createTempKubeConfigFile( + "config", """ + apiVersion: v1 + clusters: + - cluster: + server: https://api.endor.starwars.com:6443 + name: endor-cluster + contexts: + - context: + cluster: endor-cluster + user: leia-organa + name: endor-cluster + current-context: endor-cluster + kind: Config + preferences: {} + users: + - name: leia-organa + user: + token: rebel-token + """.trimIndent() + ) + val invalidFile = createTempKubeConfigFile("invalid-config", "this is not yaml") + + // when + val clusters = KubeConfigUtils.getClusters(listOf(kubeConfigFile, invalidFile)) + + // then + assertThat(clusters).containsExactly( + Cluster( + name = "endor-cluster", + url = "https://api.endor.starwars.com:6443", + token = "rebel-token" + ) + ) + } + + @Test + fun `#isCurrentUserTokenAuth returns true if current user has token`() { + // given + val kubeConfigFile = createTempKubeConfigFile( + "config", + """ + apiVersion: v1 + clusters: + - name: hoth-cluster + cluster: + server: https://api.hoth.starwars.com:6443 + users: + - name: han-solo + user: + token: smuggler-token + contexts: + - name: echo-base + context: + cluster: hoth-cluster + user: han-solo + current-context: echo-base + """.trimIndent() + ) + val kubeConfig = KubeConfig.loadKubeConfig(kubeConfigFile.toFile().reader()) + kubeConfig.setContext("echo-base") + + // when + val isTokenAuth = KubeConfigUtils.isCurrentUserTokenAuth(kubeConfig) + + // then + assertThat(isTokenAuth).isTrue + } + + @Test + fun `#isCurrentUserTokenAuth returns false if current user has no token`() { + // given + val kubeConfigFile = createTempKubeConfigFile( + "config", + """ + apiVersion: v1 + clusters: + - name: naboo-cluster + cluster: + server: https://api.naboo.starwars.com:6443 + users: + - name: padme-amidala + user: + client-key-data: data + contexts: + - name: theed-city + context: + cluster: naboo-cluster + user: padme-amidala + current-context: theed-city + """.trimIndent() + ) + val kubeConfig = KubeConfig.loadKubeConfig(kubeConfigFile.toFile().reader()) + kubeConfig.setContext("theed-city") + + // when + val isTokenAuth = KubeConfigUtils.isCurrentUserTokenAuth(kubeConfig) + + // then + assertThat(isTokenAuth).isFalse + } + + @Test + fun `#getAllConfigs returns default config if KUBECONFIG is not set`() { + // given + val originalUserHome = System.getProperty("user.home") + try { + System.setProperty("user.home", tempDir.toString()) + val defaultKubeConfig = createTempKubeConfigFile(".kube/config", "default config") + + // when + val allConfigs = KubeConfigUtils.getAllConfigFiles("") + + // then + assertThat(allConfigs).containsExactly(defaultKubeConfig) + } finally { + System.setProperty("user.home", originalUserHome) + } + } + + @Test + fun `#getAllConfigs returns configFiles from KUBECONFIG env var`() { + // given + val kubeConfigFile1 = createTempKubeConfigFile("config1", "content1") + val kubeConfigFile2 = createTempKubeConfigFile("config2", "content2") + val kubeconfigEnv = "${kubeConfigFile1}${File.pathSeparator}${kubeConfigFile2}" + + // when + val allConfigs = KubeConfigUtils.getAllConfigFiles(kubeconfigEnv) + + // then + assertThat(allConfigs).containsExactly(kubeConfigFile1, kubeConfigFile2) + } + + + @Test + fun `#toString returns multiple kubeconfig files merged into a string`() { + // given + val kubeConfigFile1 = createTempKubeConfigFile("config1", "content1") + val kubeConfigFile2 = createTempKubeConfigFile("config2", "content2") + + // when + val merged = KubeConfigUtils.toString(listOf(kubeConfigFile1, kubeConfigFile2)) + + // then + assertThat(merged).isEqualTo("content1\n---\ncontent2") + } + + @Test + fun `#getCurrentContext returns current context`() { + // given + val kubeConfigFile = createTempKubeConfigFile( + "config", + """ + apiVersion: v1 + contexts: + - context: + cluster: geonosis-cluster + user: jango-fett + name: arena + current-context: arena + """.trimIndent() + ) + val kubeConfig = KubeConfig.loadKubeConfig(kubeConfigFile.toFile().reader()) + kubeConfig.setContext("arena") + + // when + val currentContext = KubeConfigUtils.getCurrentContext(kubeConfig) + + // then + assertThat(currentContext?.name).isEqualTo("arena") + assertThat(currentContext?.context?.cluster).isEqualTo("geonosis-cluster") + assertThat(currentContext?.context?.user).isEqualTo("jango-fett") + } + + @Test + fun `#getCurrentClusterName returns null when current context is not set`() { + // given + val kubeConfigFile = createTempKubeConfigFile( + "config", """ + apiVersion: v1 + clusters: + - name: jakku-cluster + cluster: + server: https://api.jakku.starwars.com:6443 + users: + - name: rey-scavenger + user: + token: force-sensitive-token + contexts: + - context: + cluster: jakku-cluster + user: rey-scavenger + name: niima-outpost + """.trimIndent() + ) + val kubeconfigEnv = kubeConfigFile.toString() + + // when + val clusterName = KubeConfigUtils.getCurrentClusterName(kubeconfigEnv) + + // then + assertThat(clusterName).isNull() + } + + @Test + fun `#getCurrentClusterName returns name of cluster set in current context`() { + // given + val kubeConfigFile = createTempKubeConfigFile( + "config", """ + apiVersion: v1 + clusters: + - name: bespin-cluster + cluster: + server: https://api.bespin.starwars.com:6443 + users: + - name: lando-calrissian + user: + token: cloud-city-token + contexts: + - context: + cluster: bespin-cluster + user: lando-calrissian + name: cloud-city + current-context: cloud-city + """.trimIndent() + ) + val kubeconfigEnv = kubeConfigFile.toString() + + // when + val clusterName = KubeConfigUtils.getCurrentClusterName(kubeconfigEnv) + + // then + assertThat(clusterName).isEqualTo("bespin-cluster") + } + + @Test + fun `#getAllConfigs returns configs with path set`() { + // given + val kubeConfigFile = createTempKubeConfigFile( + "config", """ + apiVersion: v1 + clusters: + - name: coruscant-cluster + cluster: + server: https://api.coruscant.starwars.com:6443 + users: + - name: mace-windu + user: + token: jedi-council-token + contexts: + - context: + cluster: coruscant-cluster + user: mace-windu + name: jedi-temple + current-context: jedi-temple + """.trimIndent() + ) + + // when + val configs = KubeConfigUtils.getAllConfigs(listOf(kubeConfigFile)) + + // then + assertThat(configs).hasSize(1) + assertThat(configs[0].path).isEqualTo(kubeConfigFile) + } + + @Test + fun `#getAllConfigs returns multiple configs from file with multiple documents`() { + // given + val kubeConfigFile = createTempKubeConfigFile( + "config", """ + apiVersion: v1 + clusters: + - name: mustafar-cluster + cluster: + server: https://api.mustafar.starwars.com:6443 + --- + apiVersion: v1 + clusters: + - name: kamino-cluster + cluster: + server: https://api.kamino.starwars.com:6443 + """.trimIndent() + ) + + // when + val configs = KubeConfigUtils.getAllConfigs(listOf(kubeConfigFile)) + + // then + assertThat(configs).hasSize(2) + assertThat(configs[0].path).isEqualTo(kubeConfigFile) + assertThat(configs[1].path).isEqualTo(kubeConfigFile) + } + + @Test + fun `#getAllConfigs returns empty list when file contains invalid documents`() { + // given + val invalidFile = createTempKubeConfigFile("invalid-config", "not valid yaml") + + // when + val configs = KubeConfigUtils.getAllConfigs(listOf(invalidFile)) + + // then + assertThat(configs).isEmpty() + } + + @Test + fun `#getConfigByUser returns config containing the user`() { + // given + val kubeConfigFile = createTempKubeConfigFile( + "config", """ + apiVersion: v1 + clusters: + - name: alderaan-cluster + cluster: + server: https://api.alderaan.starwars.com:6443 + users: + - name: bail-organa + user: + token: senator-token + contexts: + - context: + cluster: alderaan-cluster + user: bail-organa + name: alderaan-context + """.trimIndent() + ) + val configs = KubeConfigUtils.getAllConfigs(listOf(kubeConfigFile)) + val context = KubeConfigNamedContext( + KubeConfigContext("bail-organa", "alderaan-cluster") + ) + + // when + val config = KubeConfigUtils.getConfigByUser(context, configs) + + // then + assertThat(config).isNotNull + assertThat(config).isEqualTo(configs[0]) + } + + @Test + fun `#getConfigByUser returns null when user not found`() { + // given + val kubeConfigFile = createTempKubeConfigFile( + "config", """ + apiVersion: v1 + clusters: + - name: dantooine-cluster + cluster: + server: https://api.dantooine.starwars.com:6443 + users: + - name: obi-wan-kenobi + user: + token: old-ben-token + contexts: + - context: + cluster: dantooine-cluster + user: obi-wan-kenobi + name: dantooine-context + """.trimIndent() + ) + val configs = KubeConfigUtils.getAllConfigs(listOf(kubeConfigFile)) + val context = KubeConfigNamedContext( + KubeConfigContext("unknown-user", "dantooine-cluster") + ) + + // when + val config = KubeConfigUtils.getConfigByUser(context, configs) + + // then + assertThat(config).isNull() + } + + @Test + fun `#getConfigWithCurrentContext returns first config with current context`() { + // given + val kubeConfigFile1 = createTempKubeConfigFile( + "config1", """ + apiVersion: v1 + clusters: + - name: tatooine-cluster + cluster: + server: https://api.tatooine.starwars.com:6443 + users: + - name: luke-skywalker + user: + token: jedi-token + contexts: + - context: + cluster: tatooine-cluster + user: luke-skywalker + name: tatooine-context + current-context: tatooine-context + """.trimIndent() + ) + val kubeConfigFile2 = createTempKubeConfigFile( + "config2", """ + apiVersion: v1 + clusters: + - name: dagobah-cluster + cluster: + server: https://api.dagobah.starwars.com:6443 + users: + - name: yoda-master + user: + token: force-token + contexts: + - context: + cluster: dagobah-cluster + user: yoda-master + name: dagobah-context + current-context: dagobah-context + """.trimIndent() + ) + val configs = KubeConfigUtils.getAllConfigs(listOf(kubeConfigFile1, kubeConfigFile2)) + + // when + val config = KubeConfigUtils.getConfigWithCurrentContext(configs) + + // then + assertThat(config).isNotNull + assertThat(config).isEqualTo(configs[0]) + assertThat(config?.currentContext).isEqualTo("tatooine-context") + } + + @Test + fun `#getConfigWithCurrentContext returns null when no configs have current context`() { + // given + val kubeConfigFile1 = createTempKubeConfigFile( + "config1", """ + apiVersion: v1 + clusters: + - name: tatooine-cluster + cluster: + server: https://api.tatooine.starwars.com:6443 + users: + - name: luke-skywalker + user: + token: jedi-token + contexts: + - context: + cluster: tatooine-cluster + user: luke-skywalker + name: tatooine-context + """.trimIndent() + ) + val kubeConfigFile2 = createTempKubeConfigFile( + "config2", """ + apiVersion: v1 + clusters: + - name: dagobah-cluster + cluster: + server: https://api.dagobah.starwars.com:6443 + users: + - name: yoda-master + user: + token: force-token + contexts: + - context: + cluster: dagobah-cluster + user: yoda-master + name: dagobah-context + """.trimIndent() + ) + val configs = KubeConfigUtils.getAllConfigs(listOf(kubeConfigFile1, kubeConfigFile2)) + + // when + val config = KubeConfigUtils.getConfigWithCurrentContext(configs) + + // then + assertThat(config).isNull() + } + + @Test + fun `#getConfigWithCurrentContext returns null when all configs have empty current context`() { + // given + val kubeConfigFile1 = createTempKubeConfigFile( + "config1", """ + apiVersion: v1 + clusters: + - name: tatooine-cluster + cluster: + server: https://api.tatooine.starwars.com:6443 + users: + - name: luke-skywalker + user: + token: jedi-token + contexts: + - context: + cluster: tatooine-cluster + user: luke-skywalker + name: tatooine-context + current-context: "" + """.trimIndent() + ) + val kubeConfigFile2 = createTempKubeConfigFile( + "config2", """ + apiVersion: v1 + clusters: + - name: dagobah-cluster + cluster: + server: https://api.dagobah.starwars.com:6443 + users: + - name: yoda-master + user: + token: force-token + contexts: + - context: + cluster: dagobah-cluster + user: yoda-master + name: dagobah-context + current-context: "" + """.trimIndent() + ) + val configs = KubeConfigUtils.getAllConfigs(listOf(kubeConfigFile1, kubeConfigFile2)) + + // when + val config = KubeConfigUtils.getConfigWithCurrentContext(configs) + + // then + assertThat(config).isNull() + } + + @Test + fun `#getConfigWithCurrentContext returns first config when multiple configs have current context`() { + // given + val kubeConfigFile1 = createTempKubeConfigFile( + "config1", """ + apiVersion: v1 + clusters: + - name: tatooine-cluster + cluster: + server: https://api.tatooine.starwars.com:6443 + users: + - name: luke-skywalker + user: + token: jedi-token + contexts: + - context: + cluster: tatooine-cluster + user: luke-skywalker + name: tatooine-context + current-context: tatooine-context + """.trimIndent() + ) + val kubeConfigFile2 = createTempKubeConfigFile( + "config2", """ + apiVersion: v1 + clusters: + - name: dagobah-cluster + cluster: + server: https://api.dagobah.starwars.com:6443 + users: + - name: yoda-master + user: + token: force-token + contexts: + - context: + cluster: dagobah-cluster + user: yoda-master + name: dagobah-context + current-context: dagobah-context + """.trimIndent() + ) + val kubeConfigFile3 = createTempKubeConfigFile( + "config3", """ + apiVersion: v1 + clusters: + - name: hoth-cluster + cluster: + server: https://api.hoth.starwars.com:6443 + users: + - name: han-solo + user: + token: smuggler-token + contexts: + - context: + cluster: hoth-cluster + user: han-solo + name: hoth-context + current-context: hoth-context + """.trimIndent() + ) + val configs = KubeConfigUtils.getAllConfigs(listOf(kubeConfigFile1, kubeConfigFile2, kubeConfigFile3)) + + // when + val config = KubeConfigUtils.getConfigWithCurrentContext(configs) + + // then + assertThat(config).isNotNull + assertThat(config).isEqualTo(configs[0]) + assertThat(config?.currentContext).isEqualTo("tatooine-context") + } + + @Test + fun `#getConfigWithCurrentContext returns null when configs list is empty`() { + // given + val configs = emptyList() + + // when + val config = KubeConfigUtils.getConfigWithCurrentContext(configs) + + // then + assertThat(config).isNull() + } + + @Test + fun `#urlToName returns hostname with port when port is present`() { + // given + val url = "https://api.kashyyyk.starwars.com:8080" + + // when + val name = KubeConfigUtils.urlToName(url) + + // then + assertThat(name).isEqualTo("api.kashyyyk.starwars.com-8080") + } + + @Test + fun `#urlToName returns hostname without port when port is not present`() { + // given + val url = "https://api.kashyyyk.starwars.com" + + // when + val name = KubeConfigUtils.urlToName(url) + + // then + assertThat(name).isEqualTo("api.kashyyyk.starwars.com") + } + + @Test + fun `#urlToName returns null when host is null`() { + // given + val url = "file:///path/to/file" + + // when + val name = KubeConfigUtils.urlToName(url) + + // then + assertThat(name).isNull() + } + + @Test + fun `#urlToName returns null for malformed URL`() { + // given + val url = "ht@tp://api.example.com" + + // when + val name = KubeConfigUtils.urlToName(url) + + // then + assertThat(name).isNull() + } + + @Test + fun `#sanitizeName converts to lowercase`() { + // given + val name = "DEATH-STAR" + + // when + val sanitized = KubeConfigUtils.sanitizeName(name) + + // then + assertThat(sanitized).isEqualTo("death-star") + } + + @Test + fun `#sanitizeName replaces invalid characters with hyphens`() { + // given + val name = "death_star@cluster#123" + + // when + val sanitized = KubeConfigUtils.sanitizeName(name) + + // then + assertThat(sanitized).isEqualTo("death-star-cluster-123") + } + + @Test + fun `#sanitizeName removes leading and trailing hyphens`() { + // given + val name = "---death-star---" + + // when + val sanitized = KubeConfigUtils.sanitizeName(name) + + // then + assertThat(sanitized).isEqualTo("death-star") + } + + @Test + fun `#sanitizeName truncates names longer than 253 characters`() { + // given + val longName = "a".repeat(300) + + // when + val sanitized = KubeConfigUtils.sanitizeName(longName) + + // then + assertThat(sanitized).hasSize(253) + assertThat(sanitized).isEqualTo("a".repeat(253)) + } + + @Test + fun `#sanitizeName preserves periods and hyphens`() { + // given + val name = "death-star.cluster-name" + + // when + val sanitized = KubeConfigUtils.sanitizeName(name) + + // then + assertThat(sanitized).isEqualTo("death-star.cluster-name") + } + + @Test + fun `#toString returns null when given empty list`() { + // given + val emptyList = emptyList() + + // when + val result = KubeConfigUtils.toString(emptyList) + + // then + assertThat(result).isNull() + } + + @Test + fun `#toString returns null when files contain only whitespace`() { + // given + val emptyFile = createTempKubeConfigFile("empty-config", " \n \t ") + + // when + val result = KubeConfigUtils.toString(listOf(emptyFile)) + + // then + assertThat(result).isNull() + } + + @Test + fun `#toString handles files with multiple documents separated by ---`() { + // given + val kubeConfigFile = createTempKubeConfigFile( + "config", """ + apiVersion: v1 + kind: Config + --- + apiVersion: v1 + kind: Config + """.trimIndent() + ) + + // when + val result = KubeConfigUtils.toString(listOf(kubeConfigFile)) + + // then + assertThat(result).contains("---") + assertThat(result).contains("apiVersion: v1") + } + + @Test + fun `#getCurrentContext returns null when current context is not set`() { + // given + val kubeConfigFile = createTempKubeConfigFile( + "config", """ + apiVersion: v1 + contexts: + - context: + cluster: felucia-cluster + user: aayla-secura + name: felucia-context + """.trimIndent() + ) + val kubeConfig = KubeConfig.loadKubeConfig(kubeConfigFile.toFile().reader()) + + // when + val currentContext = KubeConfigUtils.getCurrentContext(kubeConfig) + + // then + assertThat(currentContext).isNull() + } + + @Test + fun `#getCurrentContext returns null when context name does not match`() { + // given + val kubeConfigFile = createTempKubeConfigFile( + "config", """ + apiVersion: v1 + contexts: + - context: + cluster: utapau-cluster + user: ki-adi-mundi + name: utapau-context + current-context: non-existent-context + """.trimIndent() + ) + val kubeConfig = KubeConfig.loadKubeConfig(kubeConfigFile.toFile().reader()) + kubeConfig.setContext("non-existent-context") + + // when + val currentContext = KubeConfigUtils.getCurrentContext(kubeConfig) + + // then + assertThat(currentContext).isNull() + } + + @Test + fun `#getAllConfigFiles returns default config when env var is empty string`() { + // given + val originalUserHome = System.getProperty("user.home") + try { + System.setProperty("user.home", tempDir.toString()) + val defaultKubeConfig = createTempKubeConfigFile(".kube/config", "default config") + val emptyEnv = "" + + // when + val configs = KubeConfigUtils.getAllConfigFiles(emptyEnv) + + // then + assertThat(configs).containsExactly(defaultKubeConfig) + } finally { + System.setProperty("user.home", originalUserHome) + } + } + + @Test + fun `#getAllConfigFiles filters out non-existent files from env var`() { + // given + val existingFile = createTempKubeConfigFile("existing-config", "content") + val nonExistentFile = tempDir.resolve("non-existent-config") + val kubeconfigEnv = "${existingFile}${File.pathSeparator}${nonExistentFile}" + + // when + val configs = KubeConfigUtils.getAllConfigFiles(kubeconfigEnv) + + // then + assertThat(configs).containsExactly(existingFile) + } + + @Test + fun `#path extension property sets and gets path correctly`() { + // given + val kubeConfigFile = createTempKubeConfigFile( + "config", """ + apiVersion: v1 + kind: Config + """.trimIndent() + ) + val kubeConfig = KubeConfig.loadKubeConfig(kubeConfigFile.toFile().reader()) + + // when + kubeConfig.path = kubeConfigFile + + // then + assertThat(kubeConfig.path).isEqualTo(kubeConfigFile) + } + + @Test + fun `#path extension property returns null when not set`() { + // given + val kubeConfigFile = createTempKubeConfigFile( + "config", """ + apiVersion: v1 + kind: Config + """.trimIndent() + ) + val kubeConfig = KubeConfig.loadKubeConfig(kubeConfigFile.toFile().reader()) + + // when + val path = kubeConfig.path + + // then + assertThat(path).isNull() + } + + @Test + fun `#path extension property removes path when set to null`() { + // given + val kubeConfigFile = createTempKubeConfigFile( + "config", """ + apiVersion: v1 + kind: Config + """.trimIndent() + ) + val kubeConfig = KubeConfig.loadKubeConfig(kubeConfigFile.toFile().reader()) + kubeConfig.path = kubeConfigFile + + // when + kubeConfig.path = null + + // then + assertThat(kubeConfig.path).isNull() + } + + private fun createTempKubeConfigFile(name: String, content: String): Path { + val file = tempDir.resolve(name) + file.parent.createDirectories() + file.createFile() + file.writeText(content) + return file + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/openshift/ClusterTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/openshift/ClusterTest.kt index 960b9b13..324c614e 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/openshift/ClusterTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/openshift/ClusterTest.kt @@ -90,112 +90,6 @@ class ClusterTest { .isEqualTo("jedi-council@api.jedi.temple/path") } - @Test - fun `#fromUrl creates cluster from valid http URL`() { - // given - val url = "http://api.tatooine.galaxy" - - // when - val cluster = Cluster.fromUrl(url) - - // then - assertThat(cluster) - .isNotNull() - assertThat(cluster?.name) - .isEqualTo("api.tatooine.galaxy") - assertThat(cluster?.url) - .isEqualTo(url) - } - - @Test - fun `#fromUrl creates cluster from URL with port`() { - // given - val url = "https://api.solo.ship:8443" - - // when - val cluster = Cluster.fromUrl(url) - - // then - assertThat(cluster) - .isNotNull() - assertThat(cluster?.name) - .isEqualTo("api.solo.ship") // Only host, no port - assertThat(cluster?.url) - .isEqualTo(url) - } - - @Test - fun `#fromUrl creates cluster from URL with path`() { - // given - val url = "https://api.darth.vader/path" - - // when - val cluster = Cluster.fromUrl(url) - - // then - assertThat(cluster) - .isNotNull() - assertThat(cluster?.name) - .isEqualTo("api.darth.vader") // Only host, no path - assertThat(cluster?.url) - .isEqualTo(url) - } - - @Test - fun `#fromUrl returns null for empty string`() { - // given - // when - val cluster = Cluster.fromUrl("") - - // then - assertThat(cluster) - .isNull() - } - - @Test - fun `#fromUrl returns null for URL without scheme`() { - // given - // when - val cluster = Cluster.fromUrl("api.deathstar.empire") - - // then - assertThat(cluster) - .isNull() - } - - @Test - fun `#fromUrl returns null for URL without host`() { - // given - // when - val cluster = Cluster.fromUrl("https://") - - // then - assertThat(cluster) - .isNull() - } - - @Test - fun `#fromUrl returns null for malformed URL`() { - // given - // when - val cluster = Cluster.fromUrl("ht@tp://api.tatooine.galaxy") - - // then - assertThat(cluster) - .isNull() - } - - @Test - fun `#fromUrl returns null for URL with invalid characters`() { - val cluster = Cluster.fromUrl("https://api sith.galaxy") - assertThat(cluster) - .isNull() - - val cluster2 = Cluster.fromUrl("https://api@with#special.com") - assertThat(cluster2) - .isNotNull() - } - @Test fun `#equals returns true for clusters with same properties`() { // given @@ -239,22 +133,22 @@ class ClusterTest { } @Test - fun `#name returns url without scheme, port nor path`() { - val cluster1 = Cluster.fromUrl("https://jedi-temple.galaxy") + fun `#name returns url without scheme nor path`() { + val cluster1 = Cluster.fromNameAndUrl("https://jedi-temple.galaxy") assertThat(cluster1?.name) .isEqualTo("jedi-temple.galaxy") - val cluster2 = Cluster.fromUrl("http://local-transport:8080") + val cluster2 = Cluster.fromNameAndUrl("http://local-transport:8080") assertThat(cluster2?.name) - .isEqualTo("local-transport") + .isEqualTo("local-transport-8080") - val cluster3 = Cluster.fromUrl("https://rebel-base.galaxy:443/") + val cluster3 = Cluster.fromNameAndUrl("https://rebel-base.galaxy:443/") assertThat(cluster3?.name) - .isEqualTo("rebel-base.galaxy") + .isEqualTo("rebel-base.galaxy-443") - val cluster4 = Cluster.fromUrl("https://sith-tower.galaxy:9090/api/v1") + val cluster4 = Cluster.fromNameAndUrl("https://sith-tower.galaxy:9090/api/v1") assertThat(cluster4?.name) - .isEqualTo("sith-tower.galaxy") + .isEqualTo("sith-tower.galaxy-9090") } @Test @@ -275,4 +169,56 @@ class ClusterTest { assertThat(cluster4.id) .isEqualTo("jedi-council@api.jedi.temple:9090/api/v1") } + + @Test + fun `#fromNameAndUrl creates cluster from URL-only input`() { + // given + val url = "https://api.che-dev.x6e0.p1.openshiftapps.com:6443" + + // when + val cluster = Cluster.fromNameAndUrl(url) + + // then + assertThat(cluster) + .isNotNull() + assertThat(cluster?.url) + .isEqualTo(url) + assertThat(cluster?.name) + .isNotNull() + .isNotEmpty() + } + + @Test + fun `#fromNameAndUrl creates cluster from name and URL format`() { + // given + val name = "x-wing" + val url = "https://api.xwing.rebel" + val input = "$name ($url)" + + // when + val cluster = Cluster.fromNameAndUrl(input) + + // then + assertThat(cluster) + .isNotNull() + assertThat(cluster?.name) + .isEqualTo(name) + assertThat(cluster?.url) + .isEqualTo(url) + } + + @Test + fun `#fromNameAndUrl handles http URL`() { + // given + val url = "http://api.example.com:8080" + + // when + val cluster = Cluster.fromNameAndUrl(url) + + // then + assertThat(cluster) + .isNotNull() + assertThat(cluster?.url) + .isEqualTo(url) + } } diff --git a/src/test/kotlin/com/redhat/devtools/gateway/openshift/UtilsTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/openshift/UtilsTest.kt new file mode 100644 index 00000000..e674ee98 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/openshift/UtilsTest.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2024-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 + +import org.junit.jupiter.api.Test +import org.assertj.core.api.Assertions.assertThat + +class UtilsTest { + + @Test + fun `#getValue returns value from nested map`() { + // given + val map = mapOf("jedis" to mapOf("republic" to "obiwan")) + + // when + val value = Utils.getValue(map, arrayOf("jedis", "republic")) + + // then + assertThat(value).isEqualTo("obiwan") + } + + @Test + fun `#getValue returns null if path does not exist`() { + // given + val map = mapOf("jedis" to mapOf("republic" to "obiwan")) + + // when + val value = Utils.getValue(map, arrayOf("jedis", "empire")) + + // then + assertThat(value).isNull() + } + + @Test + fun `#setValue sets value in nested map`() { + // given + val map = mutableMapOf("jedis" to mutableMapOf("republic" to "obiwan")) + + // when + Utils.setValue(map, "yoda", arrayOf("jedis", "republic")) + + // then + assertThat(map["jedis"] as Map).containsEntry("republic", "yoda") + } + + @Test + fun `#setValue creates nested map if not exist`() { + // given + val map = mutableMapOf() + + // when + Utils.setValue(map, "vader", arrayOf("sith", "empire")) + + // then + assertThat(map["sith"] as Map).containsEntry("empire", "vader") + } + + @Test + fun `#getValue returns null when intermediate map is null`() { + // given + val map = mapOf("jedis" to null) + + // when + val value = Utils.getValue(map, arrayOf("jedis", "republic")) + + // then + assertThat(value).isNull() + } + + @Test + fun `#setValue handles empty path correctly`() { + // given + val map = mutableMapOf() + + // when + Utils.setValue(map, "luke", emptyArray()) + + // then + // Expect no change as an empty path is not valid for setting a value in a nested map structure + assertThat(map).isEmpty() + } + + @Test + fun `#setValue handles path with single element correctly`() { + // given + val map = mutableMapOf() + + // when + Utils.setValue(map, "han", arrayOf("smuggler")) + + // then + assertThat(map).containsEntry("smuggler", "han") + } + + @Test + fun `#getValue handles empty object`() { + // given + val map = emptyMap() + + // when + val value = Utils.getValue(map, arrayOf("jedis")) + + // then + assertThat(value).isNull() + } + + @Test + fun `#getValue handles value of different type`() { + // given + val map = mapOf("jedis" to 123) + + // when + val value = Utils.getValue(map, arrayOf("jedis", "republic")) + + // then + assertThat(value).isNull() + } +}