diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsWriter.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsWriter.kt index 67bc2a25..bc43a1bb 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsWriter.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsWriter.kt @@ -14,7 +14,6 @@ package com.redhat.devtools.gateway.auth.tls import com.redhat.devtools.gateway.kubeconfig.BlockStyleFilePersister import com.redhat.devtools.gateway.kubeconfig.KubeConfigNamedCluster import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils -import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.path import com.redhat.devtools.gateway.openshift.Utils import java.security.cert.X509Certificate @@ -33,14 +32,14 @@ object KubeConfigTlsWriter { ) // Find the kubeconfig that actually contains this cluster - val config = allConfigs.firstOrNull { kubeConfig -> - kubeConfig.clusters?.any { entry -> + val configFile = allConfigs.firstOrNull { configFile -> + configFile.config.clusters?.any { entry -> val map = entry as? Map<*, *> ?: return@any false map["name"] == namedCluster.name } == true } ?: return - val clusterEntry = config.clusters + val clusterEntry = configFile.config.clusters ?.firstOrNull { entry -> val map = entry as? Map<*, *> ?: return@firstOrNull false map["name"] == namedCluster.name @@ -59,14 +58,14 @@ object KubeConfigTlsWriter { removeInsecureSkipTlsVerify(clusterEntry) // Persist - val file = config.path?.toFile() ?: return + val file = configFile.path.toFile() val persister = BlockStyleFilePersister(file) persister.save( - config.contexts, - config.clusters, - config.users, - config.preferences, - config.currentContext + configFile.config.contexts, + configFile.config.clusters, + configFile.config.users, + configFile.config.preferences, + configFile.config.currentContext ) } 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 8de6131c..fa8f48f6 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcher.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcher.kt @@ -41,6 +41,7 @@ class FileWatcher( while (isActive) { val key = watchService.poll(100, TimeUnit.MILLISECONDS) if (key == null) { + retryUnregisteredParents() @Suppress("ConvertLongToDuration") delay(100) continue @@ -78,11 +79,9 @@ class FileWatcher( * @return this instance for chaining. */ fun addFile(path: Path): FileWatcher { - val parentDir = path.parent - if (parentDir != null - && !monitoredFiles.contains(path)) { - registerDirectory(parentDir) - monitoredFiles.add(path) + val parentDir = path.parent ?: return this + registerDirectory(parentDir) + if (monitoredFiles.add(path)) { invokeOnFileChanged(path) } return this @@ -113,10 +112,33 @@ class FileWatcher( } } + /** + * Re-registers parent directories of monitored files that were missing when [addFile] + * was called. Once such a directory exists, it is registered for watching and the + * [onFileChanged] callback is invoked for each monitored file in it, so listeners are + * notified without the caller having to re-add the files. + */ + private fun retryUnregisteredParents() { + monitoredFiles + .mapNotNull { it.parent } + .distinct() + .filter { it !in registeredDirectories.values } + .filter { Files.isDirectory(it) } + .forEach { directory -> + registerDirectory(directory) + monitoredFiles + .filter { it.parent == directory } + .forEach { file -> invokeOnFileChanged(file) } + } + } + private fun registerDirectory(directory: Path) { if (registeredDirectories.values.any { it == directory }) { return } + if (!Files.isDirectory(directory)) { + return + } val watchKey = directory.register( watchService, StandardWatchEventKinds.ENTRY_CREATE, diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigFile.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigFile.kt new file mode 100644 index 00000000..73f18e55 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigFile.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 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 java.nio.file.Path + +class KubeConfigFile( + val config: KubeConfig, + val path: Path +) + 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 8d6e9794..a4df01a6 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitor.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitor.kt @@ -85,9 +85,12 @@ class KubeConfigMonitor( } private fun startWatchingNew(newPaths: Set) { - (newPaths - fileWatcher.getMonitoredFiles()).forEach { path -> + newPaths.forEach { path -> + val alreadyMonitored = fileWatcher.getMonitoredFiles().contains(path) fileWatcher.addFile(path) - logger.info("Started monitoring kubeconfig file: $path") + if (!alreadyMonitored) { + logger.info("Started monitoring kubeconfig file: $path") + } } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt index 8faa1518..1b66cc47 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt @@ -15,18 +15,18 @@ import com.intellij.openapi.diagnostic.thisLogger import com.intellij.util.text.UniqueNameGenerator import com.redhat.devtools.gateway.auth.tls.CertificateSource import com.redhat.devtools.gateway.auth.tls.PemUtils -import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.path import com.redhat.devtools.gateway.openshift.Utils import io.kubernetes.client.persister.ConfigPersister import io.kubernetes.client.util.KubeConfig import java.io.File +import kotlin.io.path.createDirectories 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, + protected val allConfigs: List, private val persisterFactory: (File) -> ConfigPersister, ) { @@ -39,7 +39,7 @@ abstract class KubeConfigUpdate private constructor( private val USER_CLIENT_KEY_DATA = USER + "client-key-data" fun create(clusterName: String, clusterUrl: String, token: String): KubeConfigUpdate { val allConfigs = KubeConfigUtils.getAllConfigs(KubeConfigUtils.getAllConfigFiles()) - val context = KubeConfigNamedContext.getByClusterName(clusterName, allConfigs) + val context = KubeConfigNamedContext.getByClusterName(clusterName, allConfigs.map { it.config }) return if (context == null) { CreateContext(clusterName, clusterUrl, token, allConfigs) } else { @@ -49,7 +49,7 @@ abstract class KubeConfigUpdate private constructor( fun create(clusterName: String, clusterUrl: String, clientCertPem: String, clientKeyPem: String): KubeConfigUpdate { val allConfigs = KubeConfigUtils.getAllConfigs(KubeConfigUtils.getAllConfigFiles()) - val context = KubeConfigNamedContext.getByClusterName(clusterName, allConfigs) + val context = KubeConfigNamedContext.getByClusterName(clusterName, allConfigs.map { it.config }) return if (context == null) { CreateContextWithClientCert(clusterName, clusterUrl, clientCertPem, clientKeyPem, allConfigs) } else { @@ -62,41 +62,41 @@ abstract class KubeConfigUpdate private constructor( abstract fun apply() protected fun saveConfigs( - primaryConfig: KubeConfig, - currentContextConfig: KubeConfig?, + primaryConfig: KubeConfigFile, + currentContextConfig: KubeConfigFile?, currentContextName: String? ) { when { currentContextConfig == null -> saveConfig(primaryConfig) primaryConfig.path == currentContextConfig.path -> - saveConfig(primaryConfig, currentContextName ?: primaryConfig.currentContext) + saveConfig(primaryConfig, currentContextName ?: primaryConfig.config.currentContext) else -> { saveConfig(primaryConfig) - saveConfig(currentContextConfig, currentContextName ?: currentContextConfig.currentContext) + saveConfig(currentContextConfig, currentContextName ?: currentContextConfig.config.currentContext) } } } - protected fun saveConfig(config: KubeConfig, currentContext: String? = config.currentContext) { + protected fun saveConfig(configFile: KubeConfigFile, currentContext: String? = configFile.config.currentContext) { saveConfig( - config.contexts, - config.clusters, - config.users, - config.preferences, + configFile.config.contexts, + configFile.config.clusters, + configFile.config.users, + configFile.config.preferences, currentContext, - config.path + configFile.path ) } protected fun saveConfig( - config: KubeConfig, + configFile: KubeConfigFile, users: ArrayList, clusters: ArrayList, contexts: ArrayList, currentContext: String ) { - saveConfig(contexts, clusters, users, config.preferences, currentContext, config.path) + saveConfig(contexts, clusters, users, configFile.config.preferences, currentContext, configFile.path) } private fun saveConfig( @@ -105,11 +105,15 @@ abstract class KubeConfigUpdate private constructor( users: ArrayList?, preferences: Any?, currentContext: String?, - path: Path? + path: Path ) { - val file = path?.toFile() ?: run { - thisLogger().info("Could not write kubeconfig file. Path missing.") - return + val file = path.toFile() + path.parent?.let { parent -> + try { + parent.createDirectories() + } catch (e: java.io.IOException) { + thisLogger().warn("Could not create parent directory for ${path}", e) + } } val persister = persisterFactory(file) persister.save( @@ -174,13 +178,13 @@ abstract class KubeConfigUpdate private constructor( return ContextEntries(updatedUsers, updatedClusters, updatedContexts, context.name) } - protected fun uniqueUserName(allConfigs: List): String { - val existingUserNames = getAllExistingNames(allConfigs) { it.users } + protected fun uniqueUserName(allConfigs: List): String { + val existingUserNames = getAllExistingNames(allConfigs) { it.config.users } return UniqueNameGenerator.generateUniqueName(clusterName, existingUserNames) } - private fun createCluster(allConfigs: List): KubeConfigNamedCluster { - val existingClusterNames = getAllExistingNames(allConfigs) { it.clusters } + private fun createCluster(allConfigs: List): KubeConfigNamedCluster { + val existingClusterNames = getAllExistingNames(allConfigs) { it.config.clusters } val uniqueClusterName = UniqueNameGenerator.generateUniqueName(clusterName, existingClusterNames) return KubeConfigNamedCluster( @@ -192,9 +196,9 @@ abstract class KubeConfigUpdate private constructor( private fun createContext( user: KubeConfigNamedUser, cluster: KubeConfigNamedCluster, - allConfigs: List + allConfigs: List ): KubeConfigNamedContext { - val existingContextNames = getAllExistingNames(allConfigs) { it.contexts } + val existingContextNames = getAllExistingNames(allConfigs) { it.config.contexts } val defaultContextName = KubeConfigNamedContext.toName(user.name, cluster.name) val uniqueContextName = UniqueNameGenerator.generateUniqueName(defaultContextName, existingContextNames) @@ -205,8 +209,8 @@ abstract class KubeConfigUpdate private constructor( } private fun getAllExistingNames( - allConfigs: List, - extractList: (KubeConfig) -> List<*>? + allConfigs: List, + extractList: (KubeConfigFile) -> List<*>? ): Set { return allConfigs .flatMap { config -> extractList(config) ?: emptyList() } @@ -222,18 +226,18 @@ abstract class KubeConfigUpdate private constructor( clusterUrl: String, token: String, private val context: KubeConfigNamedContext, - allConfigs: List, + allConfigs: List, persisterFactory: (File) -> ConfigPersister = { BlockStyleFilePersister(it) }, ) : KubeConfigUpdate(clusterName, clusterUrl, token, allConfigs, persisterFactory) { override fun apply() { - val config = KubeConfigUtils.getConfigByUser(context, allConfigs) ?: return - setTokenFor(context.context.user, config) + val configFile = KubeConfigUtils.getConfigByUser(context, allConfigs) ?: return + setTokenFor(context.context.user, configFile.config) - val currentContextConfig = KubeConfigUtils.getConfigWithCurrentContext(allConfigs) ?: config - currentContextConfig.setContext(context.name) + val currentContextConfig = KubeConfigUtils.getConfigWithCurrentContext(allConfigs) ?: configFile + currentContextConfig.config.setContext(context.name) - saveConfigs(config, currentContextConfig, context.name) + saveConfigs(configFile, currentContextConfig, context.name) } private fun setTokenFor(username: String, config: KubeConfig) { @@ -250,20 +254,21 @@ abstract class KubeConfigUpdate private constructor( clusterName: String, clusterUrl: String, private val authToken: String, - allConfigs: List, + allConfigs: List, persisterFactory: (File) -> ConfigPersister = { BlockStyleFilePersister(it) }, ) : KubeConfigUpdate(clusterName, clusterUrl, authToken, allConfigs, persisterFactory) { override fun apply() { - val config = allConfigs.firstOrNull() ?: return + val configFile = allConfigs.firstOrNull() + ?: KubeConfigUtils.newEmptyConfig(KubeConfigUtils.getWritableConfig()) val user = KubeConfigNamedUser( KubeConfigUser.tokenOnly(authToken), uniqueUserName(allConfigs) ) - val entries = createContext(user, config.users, config.clusters, config.contexts) - config.setContext(entries.currentContextName) + val entries = createContext(user, configFile.config.users, configFile.config.clusters, configFile.config.contexts) + configFile.config.setContext(entries.currentContextName) - saveConfig(config, entries.users, entries.clusters, entries.contexts, entries.currentContextName) + saveConfig(configFile, entries.users, entries.clusters, entries.contexts, entries.currentContextName) } } @@ -273,18 +278,18 @@ abstract class KubeConfigUpdate private constructor( private val clientCertPem: String, private val clientKeyPem: String, private val context: KubeConfigNamedContext, - allConfigs: List, + allConfigs: List, persisterFactory: (File) -> ConfigPersister = { BlockStyleFilePersister(it) }, ) : KubeConfigUpdate(clusterName, clusterUrl, "", allConfigs, persisterFactory) { override fun apply() { - val config = KubeConfigUtils.getConfigByUser(context, allConfigs) ?: return - setClientCert(config, context.context.user) + val configFile = KubeConfigUtils.getConfigByUser(context, allConfigs) ?: return + setClientCert(configFile.config, context.context.user) - val currentContextConfig = KubeConfigUtils.getConfigWithCurrentContext(allConfigs) ?: config - currentContextConfig.setContext(context.name) + val currentContextConfig = KubeConfigUtils.getConfigWithCurrentContext(allConfigs) ?: configFile + currentContextConfig.config.setContext(context.name) - saveConfigs(config, currentContextConfig, context.name) + saveConfigs(configFile, currentContextConfig, context.name) } private fun setClientCert(config: KubeConfig, username: String) { @@ -301,12 +306,13 @@ abstract class KubeConfigUpdate private constructor( clusterUrl: String, private val clientCertPem: String, private val clientKeyPem: String, - allConfigs: List, + allConfigs: List, persisterFactory: (File) -> ConfigPersister = { BlockStyleFilePersister(it) }, ) : KubeConfigUpdate(clusterName, clusterUrl, "", allConfigs, persisterFactory) { override fun apply() { - val config = allConfigs.firstOrNull() ?: return + val configFile = allConfigs.firstOrNull() + ?: KubeConfigUtils.newEmptyConfig(KubeConfigUtils.getWritableConfig()) val user = KubeConfigNamedUser( KubeConfigUser.clientCertOnly( CertificateSource.fromData(clientCertPem), @@ -314,10 +320,10 @@ abstract class KubeConfigUpdate private constructor( ), uniqueUserName(allConfigs) ) - val contextEntries = createContext(user, config.users, config.clusters, config.contexts) - config.setContext(contextEntries.currentContextName) + val contextEntries = createContext(user, configFile.config.users, configFile.config.clusters, configFile.config.contexts) + configFile.config.setContext(contextEntries.currentContextName) - saveConfig(config, contextEntries.users, contextEntries.clusters, contextEntries.contexts, contextEntries.currentContextName) + saveConfig(configFile, contextEntries.users, contextEntries.clusters, contextEntries.contexts, contextEntries.currentContextName) } } } 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 cfcae263..25d35e07 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt @@ -19,7 +19,6 @@ 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 @@ -55,7 +54,8 @@ object KubeConfigUtils { logger.info("Loaded ${kubeConfigs.size} kubeconfig files from paths: $kubeconfigPaths") val clusters = kubeConfigs - .flatMap { kubeConfig -> + .flatMap { kubeConfigFile -> + val kubeConfig = kubeConfigFile.config kubeConfig.clusters?.mapNotNull { cluster -> val namedCluster = KubeConfigNamedCluster.fromMap(cluster as Map<*, *>) ?: return@mapNotNull null val kubeUser = KubeConfigNamedUser.getUserForCluster(namedCluster.name, kubeConfig) @@ -70,7 +70,7 @@ object KubeConfigUtils { return clusters } - private fun toKubeConfigs(kubeconfigPaths: List): List { + private fun toKubeConfigs(kubeconfigPaths: List): List { return kubeconfigPaths .filter { path -> val valid = isValid(path) @@ -88,7 +88,8 @@ object KubeConfigUtils { val kubeConfig = KubeConfig.loadKubeConfig(content.reader()) logger.info("loaded kubeconfig from: $path") - kubeConfig + kubeConfig.setFile(path.toFile()) + KubeConfigFile(kubeConfig, path) } catch (t: Throwable) { logger.debug("Error loading kubeconfig file '$path': ${t.message}", t) null @@ -124,11 +125,18 @@ object KubeConfigUtils { .map { Path(it) } } - private fun getDefaultConfigs(): List { - return listOfNotNull( - Path(System.getProperty("user.home"), ".kube", "config") - .takeIf { isValid(it) } - ) + fun getDefaultConfig(): Path = + Path(System.getProperty("user.home"), ".kube", "config") + + fun getWritableConfig(kubeconfigEnv: String? = null): Path { + val envPaths = getEnvConfigs(kubeconfigEnv) + return if (envPaths.isNotEmpty()) envPaths.first() else getDefaultConfig() + } + + fun newEmptyConfig(path: Path): KubeConfigFile { + val config = KubeConfig(ArrayList(), ArrayList(), ArrayList()) + config.setFile(path.toFile()) + return KubeConfigFile(config, path) } fun getAllConfigFiles(kubeconfigEnv: String? = null): List { @@ -136,7 +144,7 @@ object KubeConfigUtils { return if (envPaths.isNotEmpty()) { envPaths.filter { isValid(it) } } else { - getDefaultConfigs() + listOf(getDefaultConfig()) } } @@ -145,14 +153,13 @@ object KubeConfigUtils { && paths.isRegularFile() } - fun getAllConfigs(files: List): List { + fun getAllConfigs(files: List): List { return files.mapNotNull { file -> try { val document = file.toFile().readText() - val kubeConfig = KubeConfig.loadKubeConfig(document.reader()) - kubeConfig?.apply { - path = file - } + val kubeConfig = KubeConfig.loadKubeConfig(document.reader()) ?: return@mapNotNull null + kubeConfig.setFile(file.toFile()) + KubeConfigFile(kubeConfig, file) } catch (e: Throwable) { logger.debug("Could not parse kubeconfig document", e) null @@ -205,22 +212,22 @@ object KubeConfigUtils { } } - fun getConfigByUser(context: KubeConfigNamedContext, allConfigs: List): KubeConfig? { + fun getConfigByUser(context: KubeConfigNamedContext, allConfigs: List): KubeConfigFile? { val contextUser = context.context.user return getConfigByUser(contextUser, allConfigs) } - private fun getConfigByUser(userName: String, allConfigs: List): KubeConfig? { + private fun getConfigByUser(userName: String, allConfigs: List): KubeConfigFile? { return allConfigs - .firstOrNull { config -> - KubeConfigNamedUser.getByName(userName, config) != null + .firstOrNull { configFile -> + KubeConfigNamedUser.getByName(userName, configFile.config) != null } } - fun getConfigWithCurrentContext(allConfigs: List): KubeConfig? { + fun getConfigWithCurrentContext(allConfigs: List): KubeConfigFile? { return allConfigs - .firstOrNull { config -> - !config.currentContext.isNullOrBlank() + .firstOrNull { configFile -> + !configFile.config.currentContext.isNullOrBlank() } } @@ -293,17 +300,4 @@ object KubeConfigUtils { return mergedConfig } - - 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/apiclient/LinkClientBuilder.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/LinkClientBuilder.kt index 32f9dc2a..888e9b52 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/LinkClientBuilder.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/LinkClientBuilder.kt @@ -38,7 +38,7 @@ class LinkClientBuilder( return defaultClient() } - val kubeConfig = configUtils.mergeConfigs(allConfigs) + val kubeConfig = configUtils.mergeConfigs(allConfigs.map { it.config }) val client = ClientBuilder.kubeconfig(kubeConfig).build() client.httpClient = IdeHttpProxy.configure(client.httpClient.newBuilder()).build() applyReadTimeout(client) 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 d15b35ec..ef99a459 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 @@ -265,7 +265,7 @@ class DevSpacesServerStepView( withContext(Dispatchers.IO) { KubeConfigUtils.getAllConfigs( KubeConfigUtils.getAllConfigFiles() - ) + ).map { it.config } } }, kubeConfigWriter = { namedCluster, certs -> diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcherTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcherTest.kt index f539322f..70d487e2 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcherTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcherTest.kt @@ -84,6 +84,37 @@ class FileWatcherTest { assertThat(watcher.getMonitoredFiles()).containsExactly(nonExistentFile) } + @Test + fun `#addFile() tracks a file when its parent directory does not exist`() = runTest { + var onFileChangedCount = 0 + watcher.onFileChanged { onFileChangedCount++ } + val missingParent = tempDir.resolve("missing-kube") + val path = missingParent.resolve("config") + + watcher.addFile(path) + advanceUntilIdle() + + assertThat(watcher.getMonitoredFiles()).contains(path) + assertThat(watcher.getWatchedDirectories()).doesNotContain(missingParent) + assertThat(onFileChangedCount).isEqualTo(1) + } + + @Test + fun `#addFile() registers the parent once it becomes a directory`() = runTest { + var onFileChangedCount = 0 + watcher.onFileChanged { onFileChangedCount++ } + val missingParent = tempDir.resolve("missing-kube") + val path = missingParent.resolve("config") + + watcher.addFile(path) + java.nio.file.Files.createDirectories(missingParent) + watcher.addFile(path) + advanceUntilIdle() + + assertThat(watcher.getWatchedDirectories()).contains(missingParent) + assertThat(onFileChangedCount).isEqualTo(1) + } + @Test fun `#addFile() invokes callback for each file when multiple are added`() = runTest { var onFileChangedCount = 0 @@ -247,31 +278,68 @@ class FileWatcherTest { } @Test - fun `#onFileChanged() is invoked when a watched file is deleted`() = runBlocking { - val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - val ioWatcher = FileWatcher(ioScope, Dispatchers.IO) - try { - val callbackReceived = CompletableDeferred() - var notifyCount = 0 - ioWatcher.onFileChanged { path -> - notifyCount++ - if (notifyCount > 1) { - callbackReceived.complete(path) + fun `#onFileChanged() is invoked when a watched file is deleted`() { + runBlocking { + val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val ioWatcher = FileWatcher(ioScope, Dispatchers.IO) + try { + val callbackReceived = CompletableDeferred() + var notifyCount = 0 + ioWatcher.onFileChanged { path -> + notifyCount++ + if (notifyCount > 1) { + callbackReceived.complete(path) + } + } + ioWatcher.start() + ioWatcher.addFile(testFile) + delay(200) + + testFile.deleteExisting() + + @Suppress("ConvertLongToDuration") + withTimeout(5_000) { + assertThat(callbackReceived.await()).isEqualTo(testFile) } + } finally { + ioWatcher.stop() + ioScope.cancel() } - ioWatcher.start() - ioWatcher.addFile(testFile) - delay(200) + } + } - testFile.deleteExisting() + @Test + fun `#onFileChanged() is invoked when a missing parent directory appears after start`() { + runBlocking { + val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val ioWatcher = FileWatcher(ioScope, Dispatchers.IO) + try { + val callbackReceived = CompletableDeferred() + var notifyCount = 0 + val path = tempDir.resolve("missing-kube").resolve("config") + ioWatcher.onFileChanged { p -> + notifyCount++ + // First notify is from addFile; complete on a subsequent notify. + if (notifyCount > 1) { + callbackReceived.complete(p) + } + } + ioWatcher.start() + ioWatcher.addFile(path) + delay(200) - @Suppress("ConvertLongToDuration") - withTimeout(5_000) { - assertThat(callbackReceived.await()).isEqualTo(testFile) + java.nio.file.Files.createDirectories(path.parent) + path.writeText("new content") + + @Suppress("ConvertLongToDuration") + withTimeout(5_000) { + assertThat(callbackReceived.await()).isEqualTo(path) + } + assertThat(ioWatcher.getWatchedDirectories()).contains(path.parent) + } finally { + ioWatcher.stop() + ioScope.cancel() } - } finally { - ioWatcher.stop() - ioScope.cancel() } } 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 4d426f13..15772196 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt @@ -30,6 +30,7 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.createFile @@ -193,6 +194,31 @@ class KubeConfigMonitorTest { assertThat(fileWatcher.getMonitoredFiles()).containsExactly(kubeconfigPath2) verify(exactly = 1) { mockKubeConfigUtils.getClusters(listOf(kubeconfigPath2)) } } + + @Test + fun `#updateMonitoredPaths registers parent after it is created`() = runTest(testDispatcher) { + val path = tempDir.resolve("missing-kube").resolve("config") + every { mockKubeConfigUtils.getAllConfigFiles(any()) } returns listOf(path) + every { mockKubeConfigUtils.getClusters(any()) } returns emptyList() + + val emissions = collectClusterEmissions() + advanceUntilIdle() + + kubeconfigMonitor.start() + advanceUntilIdle() + + assertThat(fileWatcher.getMonitoredFiles()).contains(path) + assertThat(fileWatcher.getWatchedDirectories()).doesNotContain(path.parent) + assertThat(emissions).containsExactly(emptyList()) + + Files.createDirectories(path.parent) + + kubeconfigMonitor.updateMonitoredPaths() + advanceUntilIdle() + + assertThat(fileWatcher.getWatchedDirectories()).contains(path.parent) + } + @Test fun `#stop should not cancel the provided scope`() = runTest(testDispatcher) { val mockScope = mockk(relaxed = true) diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigTestHelpers.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigTestHelpers.kt index 0e8875dc..ebb9dacc 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigTestHelpers.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigTestHelpers.kt @@ -12,7 +12,6 @@ package com.redhat.devtools.gateway.kubeconfig import com.redhat.devtools.gateway.auth.tls.PemUtils -import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.path import io.kubernetes.client.util.KubeConfig import io.mockk.every import io.mockk.mockk @@ -50,7 +49,7 @@ object KubeConfigTestHelpers { clusterMap: MutableMap, contextMap: MutableMap, currentContext: String? = null - ): KubeConfig { + ): KubeConfigFile { val config = mockk(relaxed = true) // Stub currentContext FIRST to prevent MockK from trying to set the backing field // Use answers instead of returns to avoid MockK trying to set the Map backing field @@ -62,9 +61,8 @@ object KubeConfigTestHelpers { 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() - return config + return KubeConfigFile(config, path) } /** @@ -77,7 +75,7 @@ object KubeConfigTestHelpers { clusters: List> = emptyList(), users: List> = emptyList(), setupContextCapture: ((KubeConfig) -> Unit)? = null - ): KubeConfig { + ): KubeConfigFile { val config = mockk(relaxed = true) // Stub currentContext FIRST to prevent MockK from trying to set the backing field // Use answers instead of returns to avoid MockK trying to set the Map backing field @@ -85,10 +83,9 @@ object KubeConfigTestHelpers { 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 + return KubeConfigFile(config, path) } /** diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdateTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdateTest.kt index 334f3d49..289d081a 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdateTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdateTest.kt @@ -12,7 +12,6 @@ package com.redhat.devtools.gateway.kubeconfig import com.redhat.devtools.gateway.auth.tls.PemUtils -import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.path import com.redhat.devtools.gateway.openshift.Utils import io.kubernetes.client.persister.ConfigPersister import io.kubernetes.client.util.KubeConfig @@ -23,6 +22,7 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.io.File import java.nio.file.Path +import kotlin.io.path.exists class KubeConfigUpdateTest { @@ -133,6 +133,120 @@ class KubeConfigUpdateTest { } } + @Test + fun `#apply CreateContext creates kubeconfig when allConfigs is empty`() { + // given + val data = CreateContextTestData() + val targetPath = Path.of("/test/new-kubeconfig") + val allConfigs = emptyList() + every { KubeConfigUtils.getWritableConfig() } returns targetPath + every { KubeConfigUtils.newEmptyConfig(targetPath) } answers { + KubeConfigTestHelpers.createMockKubeConfig(targetPath) + } + + val update = KubeConfigUpdate.CreateContext(data.clusterName, data.clusterUrl, data.token, allConfigs, testPersisterFactory) + + // when + update.apply() + + // then + verify { + persisterFor(targetPath).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 CreateContextWithClientCert creates kubeconfig when allConfigs is empty`() { + // given + val data = CreateContextWithClientCertTestData() + val targetPath = Path.of("/test/new-kubeconfig") + val allConfigs = emptyList() + every { KubeConfigUtils.getWritableConfig() } returns targetPath + every { KubeConfigUtils.newEmptyConfig(targetPath) } answers { + KubeConfigTestHelpers.createMockKubeConfig(targetPath) + } + + val update = KubeConfigUpdate.CreateContextWithClientCert( + data.clusterName, + data.clusterUrl, + data.clientCertPem, + data.clientKeyPem, + allConfigs, + testPersisterFactory, + ) + + // when + update.apply() + + // then + verify { + persisterFor(targetPath).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) + verifyUserWithClientCert( + users[0] as Map<*, *>, + data.clusterName, + data.clientCertPem, + data.clientKeyPem + ) + }, + any(), + any(), + ) + } + } + + @Test + fun `#apply CreateContext creates parent directories when missing`() { + // given + val data = CreateContextTestData() + val targetPath = Path.of(System.getProperty("java.io.tmpdir")) + .resolve("kube-parent-missing-${System.nanoTime()}") + .resolve("subdir") + .resolve("config") + val allConfigs = emptyList() + every { KubeConfigUtils.getWritableConfig() } returns targetPath + every { KubeConfigUtils.newEmptyConfig(targetPath) } answers { + KubeConfigTestHelpers.createMockKubeConfig(targetPath) + } + + val update = KubeConfigUpdate.CreateContext(data.clusterName, data.clusterUrl, data.token, allConfigs, testPersisterFactory) + + // when + update.apply() + + // then - saveConfig should create the missing parent directories + assertThat(targetPath.parent!!.exists()).isTrue() + } + @Test fun `#apply CreateContextWithClientCert generates unique user name if user name already exists`() { // given @@ -927,11 +1041,10 @@ class KubeConfigUpdateTest { every { config.clusters } returns null every { config.contexts } returns null every { config.currentContext } answers { "" } - every { config.path } returns kubeConfigPath every { config.preferences } returns mockk() every { config.setContext(any()) } returns true - val allConfigs = listOf(config) + val allConfigs = listOf(KubeConfigFile(config, kubeConfigPath)) setupCreateContextMocks(data.clusterName, allConfigs, kubeConfigPath) val update = KubeConfigUpdate.CreateContext(data.clusterName, data.clusterUrl, data.token, allConfigs, testPersisterFactory) @@ -970,7 +1083,7 @@ class KubeConfigUpdateTest { // given val data = CreateContextTestData() val config = KubeConfigTestHelpers.createMockKubeConfig(kubeConfigPath) - every { config.preferences } returns null + every { config.config.preferences } returns null val allConfigs = listOf(config) setupCreateContextMocks(data.clusterName, allConfigs, kubeConfigPath) @@ -998,7 +1111,7 @@ class KubeConfigUpdateTest { val data = UpdateTokenTestData() val (existingUserMap, existingClusterMap, existingContextMap) = createUpdateTokenTestMaps(data) val config = KubeConfigTestHelpers.createMockKubeConfig(kubeConfigPath, existingUserMap, existingClusterMap, existingContextMap) - every { config.preferences } returns null + every { config.config.preferences } returns null val allConfigs = listOf(config) val mockContext = setupUpdateExistingContextMocks(data.clusterName, data.userName, data.contextName, allConfigs, config, null) @@ -1026,7 +1139,7 @@ class KubeConfigUpdateTest { val data = UpdateClientCertTestData() val (existingUserMap, existingClusterMap, existingContextMap) = createUpdateClientCertTestMaps(data) val config = KubeConfigTestHelpers.createMockKubeConfig(kubeConfigPath, existingUserMap, existingClusterMap, existingContextMap) - every { config.preferences } returns null + every { config.config.preferences } returns null val allConfigs = listOf(config) val mockContext = setupUpdateExistingContextMocks(data.clusterName, data.userName, data.contextName, allConfigs, config, null) @@ -1218,17 +1331,17 @@ class KubeConfigUpdateTest { clusterName: String, userName: String, contextName: String, - allConfigs: List, - configForUser: KubeConfig, - configForCurrentContext: KubeConfig? + allConfigs: List, + configForUser: KubeConfigFile, + configForCurrentContext: KubeConfigFile? ): KubeConfigNamedContext { mockkObject(KubeConfigNamedContext) val mockContext = mockk(relaxed = true) every { mockContext.context } returns KubeConfigContext(userName, clusterName) every { mockContext.name } returns contextName - every { KubeConfigNamedContext.getByClusterName(clusterName, allConfigs) } returns mockContext + every { KubeConfigNamedContext.getByClusterName(clusterName, allConfigs.map { it.config }) } returns mockContext every { KubeConfigUtils.getAllConfigs(any()) } returns allConfigs - val configFiles = allConfigs.mapNotNull { it.path }.distinct() + val configFiles = allConfigs.map { it.path }.distinct() every { KubeConfigUtils.getAllConfigFiles() } returns configFiles every { KubeConfigUtils.getConfigByUser(mockContext, allConfigs) } returns configForUser every { KubeConfigUtils.getConfigWithCurrentContext(allConfigs) } returns configForCurrentContext @@ -1238,11 +1351,11 @@ class KubeConfigUpdateTest { private fun setupCreateContextMocks( clusterName: String, - allConfigs: List, + allConfigs: List, path: Path ) { mockkObject(KubeConfigNamedContext) - every { KubeConfigNamedContext.getByClusterName(clusterName, allConfigs) } returns null + every { KubeConfigNamedContext.getByClusterName(clusterName, allConfigs.map { it.config }) } 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/KubeConfigUtilsTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtilsTest.kt index ad430774..f487bbf5 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtilsTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtilsTest.kt @@ -11,7 +11,6 @@ */ 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 @@ -622,7 +621,7 @@ class KubeConfigUtilsTest { // then assertThat(config).isNotNull assertThat(config).isEqualTo(configs[0]) - assertThat(config?.currentContext).isEqualTo("tatooine-context") + assertThat(config?.config?.currentContext).isEqualTo("tatooine-context") } @Test @@ -791,13 +790,13 @@ class KubeConfigUtilsTest { // then assertThat(config).isNotNull assertThat(config).isEqualTo(configs[0]) - assertThat(config?.currentContext).isEqualTo("tatooine-context") + assertThat(config?.config?.currentContext).isEqualTo("tatooine-context") } @Test fun `#getConfigWithCurrentContext returns null when configs list is empty`() { // given - val configs = emptyList() + val configs = emptyList() // when val config = KubeConfigUtils.getConfigWithCurrentContext(configs) @@ -995,7 +994,7 @@ class KubeConfigUtilsTest { } @Test - fun `#path extension property sets and gets path correctly`() { + fun `#KubeConfigFile retains path and config reference`() { // given val kubeConfigFile = createTempKubeConfigFile( "config", """ @@ -1006,47 +1005,11 @@ class KubeConfigUtilsTest { val kubeConfig = KubeConfig.loadKubeConfig(kubeConfigFile.toFile().reader()) // when - kubeConfig.path = kubeConfigFile + val file = KubeConfigFile(kubeConfig, 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() + assertThat(file.path).isEqualTo(kubeConfigFile) + assertThat(file.config).isSameAs(kubeConfig) } @Test @@ -1649,6 +1612,69 @@ class KubeConfigUtilsTest { assertThat(clusterNames).containsExactly("tatooine-cluster", "dagobah-cluster", "hoth-cluster") } + @Test + fun `#getDefaultConfig returns user home kube config path`() { + // given + val originalUserHome = System.getProperty("user.home") + try { + System.setProperty("user.home", tempDir.toString()) + + // when + val defaultConfigPath = KubeConfigUtils.getDefaultConfig() + + // then + assertThat(defaultConfigPath).isEqualTo(tempDir.resolve(".kube").resolve("config")) + } finally { + System.setProperty("user.home", originalUserHome) + } + } + + @Test + fun `#getWritableConfig returns default when env blank`() { + // given + val originalUserHome = System.getProperty("user.home") + try { + System.setProperty("user.home", tempDir.toString()) + + // when + val resolvedFromEmpty = KubeConfigUtils.getWritableConfig("") + + // then + assertThat(resolvedFromEmpty).isEqualTo(KubeConfigUtils.getDefaultConfig()) + } finally { + System.setProperty("user.home", originalUserHome) + } + } + + @Test + fun `#getWritableConfig returns first KUBECONFIG path even if missing`() { + // given + val missingFirst = tempDir.resolve("missing-a") + val missingSecond = tempDir.resolve("missing-b") + val kubeconfigEnv = "${missingFirst}${File.pathSeparator}${missingSecond}" + + // when + val resolved = KubeConfigUtils.getWritableConfig(kubeconfigEnv) + + // then + assertThat(resolved).isEqualTo(missingFirst) + } + + @Test + fun `#newEmptyConfig sets path and has empty lists`() { + // given + val path = tempDir.resolve("new-empty-config") + + // when + val config = KubeConfigUtils.newEmptyConfig(path) + + // then + assertThat(config.path).isEqualTo(path) + assertThat(config.config.clusters).isEmpty() + assertThat(config.config.users).isEmpty() + assertThat(config.config.contexts).isEmpty() + } + private fun createKubeConfig(yaml: String): KubeConfig { return KubeConfig.loadKubeConfig(java.io.StringReader(yaml)) } diff --git a/src/test/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilderTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilderTest.kt index 5c94badc..1f6d3fbe 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilderTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilderTest.kt @@ -14,6 +14,7 @@ import com.redhat.devtools.gateway.openshift.apiclient.ClientCertClientBuilder import com.redhat.devtools.gateway.openshift.apiclient.LinkClientBuilder import com.redhat.devtools.gateway.openshift.apiclient.TokenClientBuilder import com.redhat.devtools.gateway.auth.tls.SslContextFactory +import com.redhat.devtools.gateway.kubeconfig.KubeConfigFile import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import io.kubernetes.client.util.KubeConfig import io.mockk.every @@ -149,8 +150,9 @@ class OpenShiftClientBuilderTest { ) kubeConfig.setContext("test-context") + val configFile = KubeConfigFile(kubeConfig, configPath) every { configUtils.getAllConfigFiles() } returns listOf(configPath) - every { configUtils.getAllConfigs(listOf(configPath)) } returns listOf(kubeConfig) + every { configUtils.getAllConfigs(listOf(configPath)) } returns listOf(configFile) every { configUtils.mergeConfigs(listOf(kubeConfig)) } returns kubeConfig val client = LinkClientBuilder(configUtils).build()