Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,10 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6

# Run tests
# Run tests (clean + disable build cache: avoid stale compileTestKotlin from shared
# remote cache when main Kotlin ABI changes, e.g. NoSuchMethodError on data class <init>)
- name: Run Tests
run: ./gradlew check
run: ./gradlew clean check --no-build-cache

# Collect Tests Result of failed tests
- name: Collect Tests Result
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,25 +210,22 @@
)
}

fun getUserTokenForCluster(clusterName: String, kubeConfig: KubeConfig): String? {
val contextEntry = KubeConfigNamedContext.getByName(clusterName, kubeConfig) ?: return null
fun getUserForCluster(clusterRefName: String, kubeConfig: KubeConfig): KubeConfigUser? {
val contextEntry = KubeConfigNamedContext.getByName(clusterRefName, 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
} as? Map<*,*> ?: return null
return fromMap(userObject)?.user?.token
} as? Map<*, *> ?: return null
return fromMap(userObject)?.user
}

fun getUserTokenForCluster(clusterName: String, kubeConfig: KubeConfig): String? =
getUserForCluster(clusterName, kubeConfig)?.token

fun getUserClientCertForCluster(clusterName: String, kubeConfig: KubeConfig): Pair<CertificateSource?, CertificateSource?>? {

Check warning on line 226 in src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt

View workflow job for this annotation

GitHub Actions / Inspect code

Unused symbol

Function "getUserClientCertForCluster" is never used
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
} as? Map<*,*> ?: return null
val user = fromMap(userObject)?.user
return Pair(user?.clientCertificate, user?.clientKey)
val user = getUserForCluster(clusterName, kubeConfig) ?: return null
return Pair(user.clientCertificate, user.clientKey)
}

fun isTokenAuth(kubeConfig: KubeConfig): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ package com.redhat.devtools.gateway.kubeconfig

import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.util.EnvironmentUtil
import com.redhat.devtools.gateway.auth.tls.CertificateSource
import com.redhat.devtools.gateway.openshift.Cluster
import io.kubernetes.client.util.KubeConfig
import java.io.File
Expand Down Expand Up @@ -44,13 +43,10 @@ object KubeConfigUtils {
.flatMap { kubeConfig ->
kubeConfig.clusters?.mapNotNull { cluster ->
val namedCluster = KubeConfigNamedCluster.fromMap(cluster as Map<*, *>) ?: return@mapNotNull null
val token = KubeConfigNamedUser.getUserTokenForCluster(namedCluster.name, kubeConfig)
val clientCert = KubeConfigNamedUser.getUserClientCertForCluster(namedCluster.name, kubeConfig)
val clientCertSource = clientCert?.first
val clientKeySource = clientCert?.second
val cluster = toCluster(namedCluster, token, clientCertSource, clientKeySource)
logger.debug("Parsed cluster: ${cluster.name} at ${cluster.url}")
cluster
val kubeUser = KubeConfigNamedUser.getUserForCluster(namedCluster.name, kubeConfig)
val clusterModel = toCluster(namedCluster, kubeUser)
logger.debug("Parsed cluster: ${clusterModel.name} at ${clusterModel.url}")
clusterModel
} ?: emptyList()
}
.distinctBy { it.id }
Expand Down Expand Up @@ -87,17 +83,17 @@ object KubeConfigUtils {

private fun toCluster(
clusterEntry: KubeConfigNamedCluster,
userToken: String?,
clientCertSource: CertificateSource?,
clientKeySource: CertificateSource?
kubeUser: KubeConfigUser?
): Cluster {
return Cluster(
url = clusterEntry.cluster.server,
name = clusterEntry.name,
certificateAuthority = clusterEntry.cluster.certificateAuthority,
token = userToken,
clientCert = clientCertSource,
clientKey = clientKeySource
token = kubeUser?.token,
clientCert = kubeUser?.clientCertificate,
clientKey = kubeUser?.clientKey,
basicUsername = kubeUser?.username,
basicPassword = kubeUser?.password,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ data class Cluster(
val certificateAuthority: CertificateSource? = null,
val token: String? = null,
val clientCert: CertificateSource? = null,
val clientKey: CertificateSource? = null
val clientKey: CertificateSource? = null,
/** From kubeconfig user entry when present (basic auth). */
val basicUsername: String? = null,
val basicPassword: String? = null,
) {
init {
require(!(token != null && clientCert != null)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ import com.redhat.devtools.gateway.kubeconfig.KubeConfigUpdate
import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils
import com.redhat.devtools.gateway.openshift.Cluster
import com.redhat.devtools.gateway.settings.DevSpacesSettings
import com.redhat.devtools.gateway.util.isCancellationException
import com.redhat.devtools.gateway.view.steps.auth.*
import com.redhat.devtools.gateway.view.ui.Dialogs
import com.redhat.devtools.gateway.view.ui.FilteringComboBox
Expand All @@ -62,24 +61,28 @@ class DevSpacesServerStepView(

private lateinit var allClusters: List<Cluster>

/**
* `clusters[].name` referenced by kubeconfig `current-context`; from [KubeConfigUtils.getCurrentClusterName].
* When the Server combo shows another cluster, the form is dirty vs that default.
*/
private var currentContextClusterName: String? = null

private val settings: ServerSettings = ServerSettings()

private lateinit var kubeconfigScope: CoroutineScope
private lateinit var kubeconfigMonitor: KubeConfigMonitor

private var saveToKubeconfig: Boolean = false

private val saveKubeconfigCheckbox = JBCheckBox(
private val saveConfigCheckbox = JBCheckBox(
DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.save_configuration")
).apply {
isOpaque = false
background = null
isSelected = saveToKubeconfig
addActionListener {
saveToKubeconfig = isSelected
}
isSelected = false
}

private val saveConfig: Boolean
get() = saveConfigCheckbox.isEnabled && saveConfigCheckbox.isSelected

private val sessionManager =
ApplicationManager.getApplication()
.getService(RedHatAuthSessionManager::class.java)
Expand All @@ -100,6 +103,7 @@ class DevSpacesServerStepView(
val editor = this.editor.editorComponent as JTextField
PasteClipboardMenu.addTo(editor)
editor.addKeyListener(createEnterKeyListener())
editor.document.addDocumentListener(onFieldChanged())
}

private val authStrategies: List<AuthenticationStrategy> by lazy {
Expand Down Expand Up @@ -150,12 +154,6 @@ class DevSpacesServerStepView(
private inline fun <reified T : AuthenticationStrategy> findStrategy(): T? =
authStrategies.firstOrNull { it is T } as? T

private fun getCurrentAuthTokenValue(): CharArray? =
when (currentStrategy?.getAuthMethod()) {
AuthMethod.TOKEN -> (currentStrategy as? TokenAuthenticationStrategy)?.tfToken?.password
else -> null // other tabs don't have a token yet
}

private fun tabPanel(p: JComponent): JComponent =
JBUI.Panels.simplePanel(p).apply {
isOpaque = true
Expand All @@ -175,13 +173,10 @@ class DevSpacesServerStepView(
addTab(strategy.getTabTitle(), tabPanel(panel))
}

addChangeListener {
addChangeListener { event ->
currentStrategy = authStrategies.getOrNull(selectedIndex)

saveKubeconfigCheckbox.isVisible =
currentStrategy?.getAuthMethod() != AuthMethod.REDHAT_SSO

enableNextButton?.invoke()
enableSaveConfigCheckbox()
}
}

Expand Down Expand Up @@ -213,7 +208,7 @@ class DevSpacesServerStepView(
}
}
row {
cell(saveKubeconfigCheckbox)
cell(saveConfigCheckbox)
}
}.apply {
isOpaque = false
Expand Down Expand Up @@ -256,7 +251,7 @@ class DevSpacesServerStepView(
private fun onClusterSelected(event: ItemEvent) {
if (event.stateChange == ItemEvent.SELECTED) {
(event.item as? Cluster)?.let { selectedCluster ->
if (allClusters.contains(selectedCluster)) {
if ( allClusters.contains(selectedCluster)) {
tfCertAuthority.text = selectedCluster.certificateAuthority?.value ?: ""
findStrategy<TokenAuthenticationStrategy>()?.tfToken?.apply {
text = selectedCluster.token
Expand All @@ -265,46 +260,84 @@ class DevSpacesServerStepView(
tfClientCert.text = selectedCluster.clientCert?.value ?: ""
tfClientKey.text = selectedCluster.clientKey?.value ?: ""
}
saveKubeconfigCheckbox.isSelected = false
findStrategy<OpenShiftCredentialsAuthenticationStrategy>()?.applyFromCluster(selectedCluster)
saveConfigCheckbox.isSelected = false
}
}
}
updateSaveKubeconfigCheckboxEnablement()
enableSaveConfigCheckbox()
}

private fun onFieldChanged(): DocumentListener = object : DocumentListener {
override fun insertUpdate(event: DocumentEvent) {
enableNextButton?.invoke()
updateSaveKubeconfigCheckboxEnablement()
enableSaveConfigCheckbox()
}

override fun removeUpdate(e: DocumentEvent) {
enableNextButton?.invoke()
updateSaveKubeconfigCheckboxEnablement()
enableSaveConfigCheckbox()
}

override fun changedUpdate(e: DocumentEvent?) {
enableNextButton?.invoke()
updateSaveKubeconfigCheckboxEnablement()
enableSaveConfigCheckbox()
}
}

private fun updateSaveKubeconfigCheckboxEnablement() {
val cluster = tfServer.selectedItem as? Cluster
val currentToken = getCurrentAuthTokenValue()
/**
* Returns the cluster that is selected.
* When the editor text does not parse as a [Cluster], we must not fall back to the combo's stale `selectedItem`:
* that would keep the old cluster and hide URL edits from [isDirty].
*
* @return the cluster that is selected or null
*/
private fun getSelectedCluster(): Cluster? {
val parsed = tfServer.editor.item as? Cluster
if (parsed != null) return parsed
val selected = tfServer.selectedItem as? Cluster ?: return null
val text = (tfServer.editor.editorComponent as JTextField).text.trim()
return selected.takeIf { it.toString() == text }
}

/**
* Returns `true` if the values in the form are dirty and may be saved.
* It is considered dirty if the following values differ from kube config:
* * cluster name or URL/CA/auth
* * selected auth strategy
* * auth strategy values
*
* @see [getKubeConfigFor]
* @see [AuthenticationStrategy.isDirty]
*/
private fun isDirty(): Boolean {
val cluster = getSelectedCluster() ?: return true
val ctxName = currentContextClusterName
if (ctxName != null && cluster.name != ctxName) return true

val config = getKubeConfigFor(cluster) ?: return true

val tokenChanged =
!cluster?.token.isNullOrBlank()
&& currentToken?.isNotEmpty() == true
&& !cluster.token.toCharArray().contentEquals(currentToken)
if (cluster.url != config.url) return true

// Only TokenAuthenticationStrategy requires token diff to enable save
val requiresTokenDiff = currentStrategy is TokenAuthenticationStrategy
if (tfCertAuthority.text.trim() != (config.certificateAuthority?.value ?: "").trim()) return true

saveKubeconfigCheckbox.isEnabled =
!allClusters.contains(cluster)
|| !requiresTokenDiff
|| tokenChanged
return currentStrategy?.isDirty(config) ?: true
}

/**
* Kubeconfig row for [cluster]. [Cluster.id] is name+URL — id match is exact until URL is edited;
* then we match by name so URL/CA/auth still compare to the saved entry.
*/
private fun getKubeConfigFor(cluster: Cluster): Cluster? =
allClusters.find { it.id == cluster.id }
?: allClusters.find { it.name == cluster.name }

private fun enableSaveConfigCheckbox() {
val isDirty = isDirty()
saveConfigCheckbox.isEnabled = isDirty
if (!isDirty) {
saveConfigCheckbox.isSelected = false // uncheck if checkbox is disabled
}
}


Expand All @@ -326,13 +359,14 @@ class DevSpacesServerStepView(
}
ApplicationManager.getApplication().invokeLater(
{
currentContextClusterName = kubeConfigCurrentCluster
val previouslySelected = tfServer.selectedItem as? Cluster?
setClusters(updatedClusters)
setSelectedCluster(
(previouslySelected)?.name ?: kubeConfigCurrentCluster,
updatedClusters
)
updateSaveKubeconfigCheckboxEnablement()
enableSaveConfigCheckbox()
},
ModalityState.stateForComponent(component)
)
Expand All @@ -345,7 +379,7 @@ class DevSpacesServerStepView(
}

override fun onNext(): Boolean {
val selectedCluster = tfServer.selectedItem as? Cluster ?: return false
val selectedCluster = getSelectedCluster() ?: return false
val server = selectedCluster.url
val serverDisplay = server.removePrefix("https://").removePrefix("http://")
val strategy = currentStrategy ?: return false
Expand Down Expand Up @@ -474,7 +508,7 @@ class DevSpacesServerStepView(
}

private suspend fun saveKubeconfig(cluster: Cluster, token: String, indicator: ProgressIndicator) {
if (!saveToKubeconfig || token.isBlank()) return
if (!saveConfig || token.isBlank()) return

try {
indicator.text = "Updating Kube config..."
Expand All @@ -496,7 +530,7 @@ class DevSpacesServerStepView(
}

private suspend fun saveKubeconfigWithCert(cluster: Cluster, clientCertPem: String, clientKeyPem: String, indicator: ProgressIndicator) {
if (!saveToKubeconfig
if (!saveConfig
|| clientCertPem.isBlank()
|| clientKeyPem.isBlank())
return
Expand Down Expand Up @@ -542,6 +576,7 @@ class DevSpacesServerStepView(
tfClientCert.text = toSelect?.clientCert?.value ?: ""
tfClientKey.text = toSelect?.clientKey?.value ?: ""
}
findStrategy<OpenShiftCredentialsAuthenticationStrategy>()?.applyFromCluster(toSelect)
}

private fun startKubeconfigMonitor() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ abstract class AbstractAuthenticationStrategy(
return (tfServer as? javax.swing.JComboBox<*>)?.selectedItem != null
}

override fun isDirty(saved: Cluster): Boolean = false

/**
* Creates a validated API client.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,9 @@ interface AuthenticationStrategy {
* Determines if the "Next" button should be enabled for this authentication method.
*/
fun isNextEnabled(): Boolean

/**
* Returns `true` if the current values in this strategy differs from the given config
*/
fun isDirty(saved: Cluster): Boolean
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,15 @@ class ClientCertificateAuthenticationStrategy(
isServerSelected()
&& tfClientCert.text.isNotBlank()
&& tfClientKey.text.isNotBlank()

/**
* Dirty vs kubeconfig only once both PEM paths/contents are filled; an empty tab after switching is not dirty.
*/
override fun isDirty(saved: Cluster): Boolean {
val cert = tfClientCert.text.trim()
val key = tfClientKey.text.trim()
if (cert.isEmpty() || key.isEmpty()) return false
return cert != (saved.clientCert?.value ?: "").trim()
|| key != (saved.clientKey?.value ?: "").trim()
}
}
Loading
Loading