From 661eb95682e1fa4ea3fed8151ad9638c4f075ee7 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Mon, 9 Jan 2023 20:23:13 +0100 Subject: [PATCH] support multiple resources in the editor (#549) Signed-off-by: Andre Dietisheim --- gradle.properties | 2 +- .../kubernetes/editor/ClusterResource.kt | 93 ++- .../kubernetes/editor/EditorFocusListener.kt | 15 +- .../editor/EditorResourceAttributes.kt | 151 ++++ .../editor/EditorResourceFactory.kt | 50 -- .../editor/EditorResourceSerialization.kt | 94 +++ .../kubernetes/editor/ResourceEditor.kt | 487 ++++++------ .../editor/ResourceEditorFactory.kt | 9 +- .../kubernetes/editor/actions/DiffAction.kt | 2 +- .../kubernetes/editor/actions/PullAction.kt | 2 +- .../kubernetes/editor/actions/PushAction.kt | 2 +- .../editor/actions/RemoveClutterAction.kt | 2 +- .../notification/DeletedNotification.kt | 2 +- .../editor/notification/ErrorNotification.kt | 3 + .../notification/NotificationActions.kt | 4 +- .../editor/notification/PullNotification.kt | 14 +- .../editor/notification/PulledNotification.kt | 7 +- .../editor/notification/PushNotification.kt | 51 +- .../editor/notification/PushedNotification.kt | 72 ++ .../editor/notification/ResourceState.kt | 40 + .../kubernetes/model/ResourceWatch.kt | 2 +- .../model/resource/ResourceIdentifier.kt | 69 ++ .../kubernetes/model/util/ResourceUtils.kt | 61 +- .../kubernetes/telemetry/TelemetryService.kt | 8 +- .../kubernetes/editor/ClusterResourceTest.kt | 162 +--- .../editor/ResourceEditorAttributesTest.kt | 175 +++++ .../editor/ResourceEditorFactoryTest.kt | 4 +- .../kubernetes/editor/ResourceEditorTest.kt | 702 ++++++++++-------- 28 files changed, 1444 insertions(+), 841 deletions(-) create mode 100644 src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/EditorResourceAttributes.kt delete mode 100644 src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/EditorResourceFactory.kt create mode 100644 src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/EditorResourceSerialization.kt create mode 100644 src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/PushedNotification.kt create mode 100644 src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/ResourceState.kt create mode 100644 src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/resource/ResourceIdentifier.kt create mode 100644 src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditorAttributesTest.kt diff --git a/gradle.properties b/gradle.properties index 70f18a6f1..1680ba798 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ projectVersion=0.7.2-SNAPSHOT jetBrainsToken=invalid jetBrainsChannel=stable intellijPluginVersion=1.10.1 -intellijCommonVersion=1.9.0-SNAPSHOT +intellijCommonVersion=1.9.0 kotlin.stdlib.default.dependency = false kotlinVersionIdea221=1.6.21 kotlinVersion=1.4.32 diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ClusterResource.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ClusterResource.kt index c102cd181..51c8177a5 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ClusterResource.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ClusterResource.kt @@ -17,6 +17,7 @@ import com.redhat.devtools.intellij.kubernetes.model.ResourceWatch import com.redhat.devtools.intellij.kubernetes.model.ResourceWatch.WatchListeners import com.redhat.devtools.intellij.kubernetes.model.context.IActiveContext import com.redhat.devtools.intellij.kubernetes.model.util.ResourceException +import com.redhat.devtools.intellij.kubernetes.model.util.areEqual import com.redhat.devtools.intellij.kubernetes.model.util.isNotFound import com.redhat.devtools.intellij.kubernetes.model.util.isSameResource import com.redhat.devtools.intellij.kubernetes.model.util.isUnsupported @@ -25,7 +26,7 @@ import io.fabric8.kubernetes.client.KubernetesClient import io.fabric8.kubernetes.client.KubernetesClientException /** - * A resource that exists on the cluster. May be [pull], [set] etc. + * A resource that exists on the cluster. May be [pull], [push] etc. * Notifies listeners of addition, removal and modification if [watch] */ open class ClusterResource protected constructor( @@ -48,8 +49,12 @@ open class ClusterResource protected constructor( private val initialResource: HasMetadata = resource protected open var updatedResource: HasMetadata? = null + private var isDeleted: Boolean = false + private var closed: Boolean = false protected open val watchListeners = WatchListeners( - {}, + { + // do nothing + }, { removed -> set(null) setDeleted(true) @@ -60,8 +65,6 @@ open class ClusterResource protected constructor( setDeleted(false) modelChange.fireModified(changed) }) - private var isDeleted: Boolean = false - private var closed: Boolean = false /** * Sets the given resource as the current value in this instance. @@ -85,7 +88,7 @@ open class ClusterResource protected constructor( /** * Returns the resource in the cluster. Returns the cached value by default, - * requests it from cluster if instructed so by the given `forceRequest` parameter. + * requests it from cluster if not cached yet or is instructed to force a new request by the given `forceRequest` parameter. * * @param forceRequest requests from server if set to true, returns the cached value otherwise * @@ -120,40 +123,6 @@ open class ClusterResource protected constructor( } } - protected open fun setDeleted(deleted: Boolean) { - synchronized(this) { - this.isDeleted = deleted - } - } - - fun isDeleted(): Boolean { - synchronized(this) { - return isDeleted - } - } - - fun isClosed(): Boolean { - synchronized(this) { - return this.closed - } - } - - fun canPush(toCompare: HasMetadata?): Boolean { - if (toCompare == null) { - return true - } - return try { - val resource = pull() - resource == null - || (isSameResource(toCompare) && isModified(toCompare)) - } catch (e: ResourceException) { - logger().warn( - "Could not request resource ${initialResource.kind} ${initialResource.metadata?.name ?: ""} from server ${context.masterUrl}", - e) - false - } - } - /** * Pushes the given resource to the cluster. The currently existing resource on the cluster is replaced * if it is the same resource in an older version. A new resource is created if the given resource @@ -173,11 +142,16 @@ open class ClusterResource protected constructor( set(updated) return updated } catch (e: KubernetesClientException) { - val details = getDetails(e) - throw ResourceException(details, e) + throw ResourceException( + getDetails(e), + e, + listOf(resource)) } catch (e: RuntimeException) { // ex. IllegalArgumentException - throw ResourceException("Could not push ${resource.kind} ${resource.metadata.name ?: ""}", e) + throw ResourceException( + "Could not push ${resource.kind} ${resource.metadata.name ?: ""}", + e, + listOf(resource)) } } @@ -191,6 +165,18 @@ open class ClusterResource protected constructor( return message.substring(detailsStart + detailsIdentifier.length) } + protected open fun setDeleted(deleted: Boolean) { + synchronized(this) { + this.isDeleted = deleted + } + } + + fun isDeleted(): Boolean { + synchronized(this) { + return isDeleted + } + } + /** * Returns `true` if the given resource version is outdated when compared to the version of the resource on the cluster. * A given resourceVersion is considered outdated if it is not equal to the resourceVersion of the resource on the cluster. @@ -201,15 +187,14 @@ open class ClusterResource protected constructor( * versions for equality (this means that you must not compare resource versions for greater-than or less-than * relationships)." * - * @param resourceVersion the resource version to compare to the version of the cluster resource - * @return true if the given resource version != resource version of the cluster resource + * @param localVersion the resource version to compare to the resource version on the cluster + * @return `true` if the given resource != resource on the cluster. `false` otherwise * * @see io.fabric8.kubernetes.api.model.ObjectMeta.resourceVersion */ - fun isOutdated(resourceVersion: String?): Boolean { - val resource = pull() - val clusterVersion = resource?.metadata?.resourceVersion ?: return false - return clusterVersion != resourceVersion + fun isOutdatedVersion(localVersion: String?): Boolean { + val clusterVersion = pull()?.metadata?.resourceVersion ?: return false + return clusterVersion != localVersion } /** @@ -232,9 +217,9 @@ open class ClusterResource protected constructor( * * @param toCompare resource to compare to the resource on the cluster */ - fun isModified(toCompare: HasMetadata?): Boolean { - val resource = pull() ?: return false - return resource != toCompare + fun isEqual(toCompare: HasMetadata?): Boolean { + val pulled = pull() ?: return false + return areEqual(pulled, toCompare) } /** @@ -316,6 +301,12 @@ open class ClusterResource protected constructor( } } + fun isClosed(): Boolean { + synchronized(this) { + return this.closed + } + } + /** * Adds the given listener to the list of listeners that should be informed of changes to the resource given * to this instance. diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/EditorFocusListener.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/EditorFocusListener.kt index 691f312bf..7c3e3f2be 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/EditorFocusListener.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/EditorFocusListener.kt @@ -17,7 +17,6 @@ import com.intellij.openapi.fileEditor.FileEditorManagerListener import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import com.redhat.devtools.intellij.kubernetes.editor.notification.ErrorNotification -import com.redhat.devtools.intellij.kubernetes.model.util.ResourceException class EditorFocusListener(private val project: Project) : FileEditorManagerListener, FileEditorManagerListener.Before { @@ -61,17 +60,9 @@ class EditorFocusListener(private val project: Project) : FileEditorManagerListe editor: FileEditor, project: Project ) { - if (e is ResourceException) { - ErrorNotification(editor, project).show( - e.message ?: "Undefined error", - e - ) - } else { - ErrorNotification(editor, project).show( - "Error contacting cluster. Make sure it's reachable, api version supported, etc.", - e.cause ?: e - ) - } + ErrorNotification(editor, project).show( + e.message ?: "Undefined error", + e) } } diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/EditorResourceAttributes.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/EditorResourceAttributes.kt new file mode 100644 index 000000000..df9151197 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/EditorResourceAttributes.kt @@ -0,0 +1,151 @@ +/******************************************************************************* + * Copyright (c) 2023 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.kubernetes.editor + +import com.intellij.openapi.Disposable +import com.redhat.devtools.intellij.kubernetes.model.IResourceModel +import com.redhat.devtools.intellij.kubernetes.model.IResourceModelListener +import com.redhat.devtools.intellij.kubernetes.model.context.IActiveContext +import com.redhat.devtools.intellij.kubernetes.model.resource.ResourceIdentifier +import io.fabric8.kubernetes.api.model.HasMetadata +import io.fabric8.kubernetes.client.KubernetesClient + +open class EditorResourceAttributes( + // for mocking purposes + private val resourceModel: IResourceModel, + // for mocking purposes + private val clusterResourceFactory: (resource: HasMetadata, context: IActiveContext?) -> ClusterResource? = + ClusterResource.Factory::create, + // for mocking purposes + private val attributes: LinkedHashMap = linkedMapOf() +) : Disposable { + + var resourceChangedListener: IResourceModelListener? = null + + fun getClusterResource(resource: HasMetadata): ClusterResource? { + return attributes[ResourceIdentifier(resource)]?.clusterResource + } + + fun getAllClusterResources(): List { + return attributes.values.mapNotNull { attributes -> attributes.clusterResource } + } + + fun setLastPushedPulled(resource: HasMetadata?) { + if (resource == null) { + return + } + getAttributes(resource)?.lastPushedPulled = resource + } + + fun getLastPulledPushed(resource: HasMetadata?): HasMetadata? { + return getAttributes(resource)?.lastPushedPulled + } + + fun setResourceVersion(resource: HasMetadata?) { + if (resource == null) { + return + } + setResourceVersion(resource) + } + + fun setResourceVersion(resource: HasMetadata?, version: String? = resource?.metadata?.resourceVersion) { + if (resource == null) { + return + } + getAttributes(resource)?.resourceVersion = version + } + + fun getResourceVersion(resource: HasMetadata): String? { + return getAttributes(resource)?.resourceVersion + } + + private fun getAttributes(resource: HasMetadata?): ResourceAttributes? { + if (resource == null) { + return null + } + return attributes[ResourceIdentifier(resource)] + } + + fun update(resources: List) { + val identifiers = resources + .map { resource -> ResourceIdentifier(resource) } + .toSet() + removeOrphanedAttributes(identifiers) + addNewAttributes(identifiers) + } + + private fun removeOrphanedAttributes(new: Set) { + val toRemove = attributes + .filter { (identifier, _) -> !new.contains(identifier) } + attributes.keys.removeAll(toRemove.keys) + toRemove.values.forEach { attributes -> attributes.dispose() } + } + + private fun addNewAttributes(identifiers: Set) { + val existing = attributes.keys + val new = identifiers.subtract(existing) + val toPut = new.associateWith { identifier -> + ResourceAttributes(identifier.resource) + } + attributes.putAll(toPut) + } + + fun disposeAll() { + dispose(attributes.keys) + } + + private fun dispose(identifiers: Collection) { + attributes + .filter { (resourceIdentifier, _) -> identifiers.contains(resourceIdentifier) } + .forEach { (_, attributes) -> + attributes.dispose() + } + } + + + override fun dispose() { + disposeAll() + } + + inner class ResourceAttributes(private val resource: HasMetadata) { + + val clusterResource: ClusterResource? = createClusterResource(resource) + + var lastPushedPulled: HasMetadata? = resource + var resourceVersion: String? = resource.metadata.resourceVersion + + private fun createClusterResource(resource: HasMetadata): ClusterResource? { + val context = resourceModel.getCurrentContext() + return if (context != null) { + val clusterResource = clusterResourceFactory.invoke( + resource, + context + ) + + val resourceChangeListener = resourceChangedListener + if (resourceChangeListener != null) { + clusterResource?.addListener(resourceChangeListener) + } + clusterResource?.watch() + clusterResource + } else { + null + } + } + + fun dispose() { + clusterResource?.close() + lastPushedPulled = null + resourceVersion = null + } + } + +} diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/EditorResourceFactory.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/EditorResourceFactory.kt deleted file mode 100644 index d9d9990de..000000000 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/EditorResourceFactory.kt +++ /dev/null @@ -1,50 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2021 Red Hat, Inc. - * Distributed under license by Red Hat, Inc. All rights reserved. - * This program is made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, - * and is available at http://www.eclipse.org/legal/epl-v20.html - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - ******************************************************************************/ -package com.redhat.devtools.intellij.kubernetes.editor - -import com.intellij.openapi.editor.Document -import com.intellij.openapi.fileEditor.FileEditor -import com.redhat.devtools.intellij.kubernetes.editor.util.getDocument -import com.redhat.devtools.intellij.kubernetes.model.util.ResourceException -import com.redhat.devtools.intellij.kubernetes.model.util.createResource -import io.fabric8.kubernetes.api.model.HasMetadata - -object EditorResourceFactory { - - /** - * Returns a [HasMetadata] for a given editor instance. - * - * @param editor to retrieve the json/yaml from - * @return [HasMetadata] for the given editor and clients - */ - fun create(editor: FileEditor): HasMetadata? { - return create(getDocument(editor)) - } - - /** - * Returns a [HasMetadata] for a given [Document] instance. - * - * @param document to retrieve the json/yaml from - * @return [HasMetadata] for the given editor and clients - */ - fun create(document: Document?): HasMetadata? { - return if (document?.text == null) { - null - } else { - try { - return createResource(document.text) - } catch (e: RuntimeException) { - throw ResourceException("Invalid kubernetes yaml/json", e.cause ?: e) - } - } - } - -} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/EditorResourceSerialization.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/EditorResourceSerialization.kt new file mode 100644 index 000000000..e2bf0a0b8 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/EditorResourceSerialization.kt @@ -0,0 +1,94 @@ +/******************************************************************************* + * Copyright (c) 2021 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.kubernetes.editor + +import com.intellij.json.JsonFileType +import com.intellij.openapi.editor.Document +import com.intellij.openapi.fileTypes.FileType +import com.redhat.devtools.intellij.kubernetes.model.util.ResourceException +import com.redhat.devtools.intellij.kubernetes.model.util.createResource +import io.fabric8.kubernetes.api.model.HasMetadata +import io.fabric8.kubernetes.client.utils.Serialization +import org.jetbrains.yaml.YAMLFileType + +object EditorResourceSerialization { + + const val RESOURCE_SEPARATOR_YAML = "\n---" + private const val RESOURCE_SEPARATOR_JSON = ",\n" + + /** + * Returns a [HasMetadata] for a given [Document] instance. + * + * @param jsonYaml serialized resources + * @return [HasMetadata] for the given editor and clients + */ + fun deserialize(jsonYaml: String?, fileType: FileType?, currentNamespace: String?): List { + return if (jsonYaml == null + || !isSupported(fileType)) { + emptyList() + } else { + val resources = jsonYaml + .split(RESOURCE_SEPARATOR_YAML) + .filter { jsonYaml -> jsonYaml.isNotBlank() } + if (resources.size > 1 + && YAMLFileType.YML != fileType) { + throw ResourceException( + "${fileType?.name ?: "File type"} is not supported for multi-resource documents. Only ${YAMLFileType.YML.name} is.") + } + try { + resources + .map { jsonYaml -> + setMissingNamespace(currentNamespace, createResource(jsonYaml)) + } + .toList() + } catch (e: RuntimeException) { + throw ResourceException("Invalid kubernetes yaml/json", e.cause ?: e) + } + } + } + + private fun setMissingNamespace(namespace: String?, resource: HasMetadata): HasMetadata { + if (resource.metadata.namespace.isNullOrEmpty() + && namespace != null) { + resource.metadata.namespace = namespace + } + return resource + } + + fun serialize(resources: Collection, fileType: FileType?): String? { + if (fileType == null) { + return null + } + if (resources.size >=2 && fileType != YAMLFileType.YML) { + throw UnsupportedOperationException( + "${fileType.name} is not supported for multi-resource documents. Only ${YAMLFileType.YML.name} is.") + } + return resources + .mapNotNull { resource -> serialize(resource, fileType) } + .joinToString(RESOURCE_SEPARATOR_YAML) + } + + private fun serialize(resource: HasMetadata, fileType: FileType): String? { + val serializer = when(fileType) { + YAMLFileType.YML -> + Serialization.yamlMapper().writerWithDefaultPrettyPrinter() + JsonFileType.INSTANCE -> + Serialization.jsonMapper().writerWithDefaultPrettyPrinter() + else -> null + } + return serializer?.writeValueAsString(resource)?.trim() + } + + private fun isSupported(fileType: FileType?): Boolean { + return fileType == YAMLFileType.YML + || fileType == JsonFileType.INSTANCE + } +} diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditor.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditor.kt index 2408fdef6..ec452dc87 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditor.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditor.kt @@ -10,7 +10,6 @@ ******************************************************************************/ package com.redhat.devtools.intellij.kubernetes.editor -import com.intellij.json.JsonFileType import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.ActionToolbar import com.intellij.openapi.application.ApplicationManager @@ -28,25 +27,35 @@ import com.intellij.psi.PsiDocumentManager import com.redhat.devtools.intellij.common.utils.MetadataClutter import com.redhat.devtools.intellij.common.validation.KubernetesResourceInfo import com.redhat.devtools.intellij.kubernetes.editor.notification.DeletedNotification +import com.redhat.devtools.intellij.kubernetes.editor.notification.DeletedOnCluster +import com.redhat.devtools.intellij.kubernetes.editor.notification.Different +import com.redhat.devtools.intellij.kubernetes.editor.notification.Error import com.redhat.devtools.intellij.kubernetes.editor.notification.ErrorNotification +import com.redhat.devtools.intellij.kubernetes.editor.notification.Identical +import com.redhat.devtools.intellij.kubernetes.editor.notification.Modified +import com.redhat.devtools.intellij.kubernetes.editor.notification.Outdated import com.redhat.devtools.intellij.kubernetes.editor.notification.PullNotification import com.redhat.devtools.intellij.kubernetes.editor.notification.PulledNotification import com.redhat.devtools.intellij.kubernetes.editor.notification.PushNotification +import com.redhat.devtools.intellij.kubernetes.editor.notification.PushedNotification +import com.redhat.devtools.intellij.kubernetes.editor.notification.ResourceState import com.redhat.devtools.intellij.kubernetes.editor.util.getDocument import com.redhat.devtools.intellij.kubernetes.editor.util.isKubernetesResource import com.redhat.devtools.intellij.kubernetes.model.IResourceModel import com.redhat.devtools.intellij.kubernetes.model.IResourceModelListener import com.redhat.devtools.intellij.kubernetes.model.context.IActiveContext -import com.redhat.devtools.intellij.kubernetes.model.util.ResettableLazyProperty -import com.redhat.devtools.intellij.kubernetes.model.util.runWithoutServerSetProperties +import com.redhat.devtools.intellij.kubernetes.model.util.MultiResourceException +import com.redhat.devtools.intellij.kubernetes.model.util.ResourceException +import com.redhat.devtools.intellij.kubernetes.model.util.areEqual +import com.redhat.devtools.intellij.kubernetes.model.util.hasGenerateName +import com.redhat.devtools.intellij.kubernetes.model.util.hasName +import com.redhat.devtools.intellij.kubernetes.model.util.isSameResource import com.redhat.devtools.intellij.kubernetes.model.util.toMessage +import com.redhat.devtools.intellij.kubernetes.model.util.toNames import com.redhat.devtools.intellij.kubernetes.model.util.toTitle import io.fabric8.kubernetes.api.model.HasMetadata -import io.fabric8.kubernetes.client.KubernetesClient -import io.fabric8.kubernetes.client.utils.Serialization import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.locks.ReentrantLock -import org.jetbrains.yaml.YAMLFileType import kotlin.concurrent.withLock /** @@ -57,17 +66,18 @@ open class ResourceEditor( private val resourceModel: IResourceModel, private val project: Project, // for mocking purposes - private val createResource: (editor: FileEditor) -> HasMetadata? = - EditorResourceFactory::create, - // for mocking purposes - private val clusterResourceFactory: (resource: HasMetadata?, context: IActiveContext?) -> ClusterResource? = - ClusterResource.Factory::create, + private val createResources: (string: String?, fileType: FileType?, currentNamespace: String?) -> List = + EditorResourceSerialization::deserialize, + private val serialize: (resources: Collection, fileType: FileType?) -> String? = + EditorResourceSerialization::serialize, // for mocking purposes private val createResourceFileForVirtual: (file: VirtualFile?) -> ResourceFile? = ResourceFile.Factory::create, // for mocking purposes private val pushNotification: PushNotification = PushNotification(editor, project), // for mocking purposes + private val pushedNotification: PushedNotification = PushedNotification(editor, project), + // for mocking purposes private val pullNotification: PullNotification = PullNotification(editor, project), // for mocking purposes private val pulledNotification: PulledNotification = PulledNotification(editor, project), @@ -79,6 +89,7 @@ open class ResourceEditor( private val getDocument: (FileEditor) -> Document? = ::getDocument, // for mocking purposes private val getPsiDocumentManager: (Project) -> PsiDocumentManager = { PsiDocumentManager.getInstance(project) }, + // for mocking purposes @Suppress("NAME_SHADOWING") private val getKubernetesResourceInfo: (VirtualFile?, Project) -> KubernetesResourceInfo? = { file, project -> com.redhat.devtools.intellij.kubernetes.editor.util.getKubernetesResourceInfo(file,project) @@ -86,14 +97,15 @@ open class ResourceEditor( // for mocking purposes private val documentChanged: AtomicBoolean = AtomicBoolean(false), // for mocking purposes - private val resourceVersion: PersistentEditorValue = PersistentEditorValue(editor), + private val diff: ResourceDiff = ResourceDiff(project), // for mocking purposes - private val diff: ResourceDiff = ResourceDiff(project) -): IResourceModelListener, Disposable { + protected val attributes: EditorResourceAttributes = EditorResourceAttributes(resourceModel) +): Disposable { init { Disposer.register(editor, this) - resourceModel.addListener(this) + attributes.resourceChangedListener = onResourceChanged() + resourceModel.addListener(onNamespaceOrContextChanged()) } companion object { @@ -104,57 +116,31 @@ open class ResourceEditor( /** mutex to exclude concurrent execution of push & watch notification **/ private val resourceChangeMutex = ReentrantLock() - private var oldClusterResource: ClusterResource? = null - private var _clusterResource: ClusterResource? = null - protected open val clusterResource: ClusterResource? - get() { - return resourceChangeMutex.withLock { - if (_clusterResource == null - || true == _clusterResource?.isClosed() - // create new cluster resource if editor has different resource (name, kind, etc. changed) - || false == _clusterResource?.isSameResource(editorResource.get()) - ) { - oldClusterResource = _clusterResource - oldClusterResource?.close() - _clusterResource = createClusterResource(editorResource.get(), resourceModel.getCurrentContext()) - lastPushedPulled.reset() - } - _clusterResource - } - } - - open var editorResource = ResettableLazyProperty { - createResource.invoke(editor) - } - - protected open var lastPushedPulled = ResettableLazyProperty { - if (true == clusterResource?.exists()) { - resourceChangeMutex.withLock { editorResource.get() } - } else { - null - } - } + private var onNamespaceContextChanged: IResourceModelListener = onNamespaceOrContextChanged() + open val editorResources = mutableListOf() /** * Updates this editor notifications and title. Does nothing if is called right after [replaceDocument]. * - * @param deletedOnCluster `true` if resource was deleted on cluster, `false` otherwise - * * @see [replaceDocument] */ - fun update(deletedOnCluster: Boolean = false) { + fun update() { if (documentChanged.compareAndSet(true, false)) { /** update triggered by change in document [replaceDocument] */ return } runAsync { try { - val resource = createResource.invoke(editor) ?: return@runAsync - resourceChangeMutex.withLock { - this.editorResource.set(resource) + val resources = createEditorResources(getDocument(editor)) + attributes.update(resources) + val states = getResourceStates(resources) + if (resources.size == 1) { + // show notification for 1 resource + showNotification(true, states.firstOrNull()) + } else { + // show notification for multiple resources + showNotification(states) } - val cluster = clusterResource - showNotifications(deletedOnCluster, resource, cluster) } catch (e: Exception) { runInUI { hideNotifications() @@ -167,40 +153,104 @@ open class ResourceEditor( } } - private fun showNotifications(deleted: Boolean, resource: HasMetadata, clusterResource: ClusterResource?) { - when { + protected fun createEditorResources(document: Document?): List { + val resources = createResources.invoke(document?.text, editor.file?.fileType, resourceModel.getCurrentNamespace()) + resourceChangeMutex.withLock { + this.editorResources.clear() + this.editorResources.addAll(resources) + } + return resources + } + + private fun getResourceStates(resources: Collection): List { + return resourceChangeMutex.withLock { + resources + .map { resource -> getResourceState(resource) } + } + } + + private fun getResourceState(resource: HasMetadata): ResourceState { + val clusterResource = attributes.getClusterResource(resource) + return when { clusterResource == null -> - showClusterErrorNotification() - deleted -> - showDeletedNotification(resource) - isModified() -> - showPushNotification(resourceVersion.get()) - clusterResource.isOutdated(resourceVersion.get()) -> - showPullNotification(resource) + Error(resource, "Error contacting cluster. Make sure it's reachable, current context set, etc.", null as String?) + !hasName(resource) + && !hasGenerateName(resource) -> + Error(resource, "Resource has neither name nor generateName.", null as String?) + clusterResource.isDeleted() -> + DeletedOnCluster(resource) + !clusterResource.exists() + || isModified(resource) -> + Modified( + resource, + clusterResource.exists(), + clusterResource.isOutdatedVersion(attributes.getResourceVersion(resource))) + clusterResource.isOutdatedVersion(attributes.getResourceVersion(resource)) -> + Outdated(resource) else -> - runInUI { - hideNotifications() - } + Identical(resource) + } + } + + private fun showNotification(showPull: Boolean, state: ResourceState?) { + if (state == null) { + return + } + when (state) { + is Error -> + showErrorNotification(state.title, state.message) + is DeletedOnCluster -> + showDeletedNotification(state.resource) + is Outdated -> + showPullNotification(state.resource) + is Modified -> + showPushNotification(showPull, listOf(state)) + is Identical -> + runInUI { hideNotifications() } + else -> + Unit + } + } + + private fun showNotification(states: Collection) { + @Suppress("UNCHECKED_CAST") + val toPush = states.filter { state -> + state is Different + && state.isPush() + } as? Collection ?: return + showPushNotification(false, toPush) + } + + private fun showErrorNotification(title: String, message: String?) { + runInUI { + hideNotifications() + errorNotification.show(title, message) } } - private fun showClusterErrorNotification() { + private fun showPushNotification(showPull: Boolean, states: Collection) { + if (states.isEmpty()) { + return + } runInUI { + // hide & show in the same UI thread runnable avoid flickering hideNotifications() - errorNotification.show( - "Error contacting cluster. Make sure it's reachable, current context set, etc.", - null - ) + pushNotification.show(showPull, states) } } - private fun showPushNotification(version: String?) { - val existsOnCluster = (true == clusterResource?.exists()) - val isOutdated = (true == clusterResource?.isOutdated(version)) + private fun showPushedNotification(states: Collection) { runInUI { // hide & show in the same UI thread runnable avoid flickering hideNotifications() - pushNotification.show(existsOnCluster, isOutdated) + pushedNotification.show(states) + } + } + + private fun showPullNotification(resource: HasMetadata) { + runInUI { + hideNotifications() + pullNotification.show(resource) } } @@ -217,31 +267,22 @@ open class ResourceEditor( pullNotification.hide() deletedNotification.hide() pushNotification.hide() + pushedNotification.hide() pulledNotification.hide() } - private fun showPullNotification(resourceInEditor: HasMetadata) { - val clusterResource = this.clusterResource ?: return - val resourceOnCluster = clusterResource.pull() ?: return - val canPush = clusterResource.canPush(resourceInEditor) - runInUI { - hideNotifications() - pullNotification.show(resourceOnCluster, canPush) - } - } - /** - * Returns `true` if the resource in the editor has changes that were not pushed (yet). + * Returns `true` if the resource in the editor has changes that don't exist in the resource + * that was last pulled/pushed from/to the cluster. + * The following properties are not taken into account: + * * [io.fabric8.kubernetes.api.model.ObjectMeta.resourceVersion] + * * [io.fabric8.kubernetes.api.model.ObjectMeta.uid] * - * @return true if the resource is dirty + * @return true if the resource is not equal to the same resource on the cluster */ - protected open fun isModified(): Boolean { - return resourceChangeMutex.withLock { - val editorResource: HasMetadata? = this.editorResource.get() - val lastPushedPulled: HasMetadata? = this.lastPushedPulled.get() - // dont compare resource version, uid - runWithoutServerSetProperties(editorResource, lastPushedPulled) { editorResource != lastPushedPulled } - } + private fun isModified(resource: HasMetadata): Boolean { + val pushedPulled = attributes.getLastPulledPushed(resource) + return !areEqual(resource, pushedPulled) } /** @@ -249,49 +290,54 @@ open class ResourceEditor( * Does nothing if it doesn't exist. */ fun pull() { - val cluster = clusterResource ?: return + val resource = resourceChangeMutex.withLock { + // do not pull for multi-resource + editorResources.firstOrNull() ?: return + } + + val cluster = attributes.getAllClusterResources().firstOrNull() ?: return + runAsync { try { - val pulled = pull(cluster) ?: return@runAsync - saveResourceVersion(pulled) + val pulled = pullAndReplace(resource, cluster) ?: return@runAsync + updateAttributes(pulled, pulled.metadata.resourceVersion) runInUI { - replaceDocument(pulled) + replaceDocument() hideNotifications() pulledNotification.show(pulled) } } catch (e: Exception) { logger().warn(e) - runInUI { - hideNotifications() - errorNotification.show( - "Could not pull ${editorResource.get()?.kind} ${editorResource.get()?.metadata?.name ?: ""}", - toMessage(e.cause) - ) - } + showErrorNotification( + "Could not pull ${resource.kind} ${resource.metadata?.name ?: ""}", + toMessage(e.cause)) } } } - private fun pull(cluster: ClusterResource): HasMetadata? { + private fun pullAndReplace(resource: HasMetadata, cluster: ClusterResource): HasMetadata? { return resourceChangeMutex.withLock { - val pulled = cluster.pull() - /** - * set editor resource now, - * watch change modification notification can get in before document was replaced - */ - this.editorResource.set(pulled) - this.lastPushedPulled.set(pulled) - pulled + val pulled = cluster.pull() ?: return null + val index = editorResources.indexOf(resource) + if (index >= 0) { + /** + * set editor resource now, + * watch change modification notification can get in before document was replaced + */ + editorResources[index] = pulled + pulled + } else { + null + } } } - private fun replaceDocument(resource: HasMetadata?) { - if (resource == null) { - return - } + private fun replaceDocument() { val manager = getPsiDocumentManager.invoke(project) val document = getDocument.invoke(editor) ?: return - val jsonYaml = serialize(resource, getFileType(document, manager)) ?: return + val jsonYaml = resourceChangeMutex.withLock { + serialize.invoke(editorResources, getFileType(document, manager)) ?: return + } if (document.text.trim() != jsonYaml) { runWriteCommand { document.replaceString(0, document.textLength, jsonYaml) @@ -311,17 +357,6 @@ open class ResourceEditor( } } - private fun serialize(resource: HasMetadata, fileType: FileType?): String? { - val serializer = when(fileType) { - YAMLFileType.YML -> - Serialization.yamlMapper().writerWithDefaultPrettyPrinter() - JsonFileType.INSTANCE -> - Serialization.jsonMapper().writerWithDefaultPrettyPrinter() - else -> null - } - return serializer?.writeValueAsString(resource)?.trim() - } - /** * Pushes the editor content to the cluster. */ @@ -331,92 +366,130 @@ open class ResourceEditor( hideNotifications() } runAsync { - try { - resourceChangeMutex.withLock { - val cluster = clusterResource ?: return@runAsync - val resource = editorResource.get() ?: return@runAsync - push(resource, cluster) - } - } catch (e: Exception) { + val states = getResourceStates(editorResources).filterIsInstance() + val toPush = getResourcesToPush(states) + val exceptions = push(toPush) + if (exceptions.isEmpty()) { + showPushedNotification(states) + } else { + val e = createMultiException(exceptions) logger().warn(e) - runInUI { - hideNotifications() - errorNotification.show( - "Could not push ${editorResource.get()?.kind} ${editorResource.get()?.metadata?.name ?: ""}", - toMessage(e.cause) - ) - } + showErrorNotification( + e.message ?: "Could not push resources to cluster.", + exceptions.joinToString( + "\n${EditorResourceSerialization.RESOURCE_SEPARATOR_YAML}\n") { e -> + toMessage(e.cause) + } + ) } } } - private fun push(resource: HasMetadata, clusterResource: ClusterResource) { - val updated = clusterResource.push(resource) - /** - * set editor resource now, - * resource change notification can get in before document was replaced - */ - saveResourceVersion(updated) - resourceChangeMutex.withLock { + private fun getResourcesToPush(states: List): List { + return states + .filter { state -> state.isPush() } + .map { state -> state.resource } + } + + private fun push(editorResources: Collection): List { + return resourceChangeMutex.withLock { + editorResources.mapNotNull { resource -> + push(resource) + } + } + } + + private fun push(resource: HasMetadata): ResourceException? { + return try { + val updated = attributes.getClusterResource(resource)?.push(resource) /** - * save pushed [resource] so that resource is not modified in later [isModified] + * set editor resource now, + * resource change notification can get in before document was replaced */ - lastPushedPulled.set(resource) + if (updated != null) { + updateAttributes(resource, updated.metadata.resourceVersion) + } + null + } catch (e: Exception) { + if (e is ResourceException) { + e + } else { + ResourceException(e.message, e) + } } } + private fun updateAttributes(resource: HasMetadata, version: String?) { + attributes.setResourceVersion(resource, version) + attributes.setLastPushedPulled(resource) + } + + private fun createMultiException(errors: List): MultiResourceException { + val message = errors.mapNotNull { error -> + toNames(error.resources) + }.joinToString() + return MultiResourceException("Could not push $message", errors) + } + fun diff() { - val clusterResource = clusterResource?.pull() ?: return val manager = getPsiDocumentManager.invoke(project) val file = editor.file ?: return runInUI { val document = getDocument.invoke(editor) ?: return@runInUI - val serialized = serialize(clusterResource, getFileType(document, manager)) ?: return@runInUI + val resourcesOnCluster = attributes.getAllClusterResources() + .mapNotNull { clusterResource -> clusterResource.pull() } + val serialized = serialize(resourcesOnCluster, getFileType(document, manager)) ?: return@runInUI val documentBeforeDiff = document.text - diff.open(file, serialized) { onDiffClosed(clusterResource, documentBeforeDiff) } + diff.open(file, serialized) { onDiffClosed(documentBeforeDiff) } } } /* - * Protected visibility for testing purposes: - * Tried capturing callback parameter in #diff and running it, but it didn't work. - * Document mock didn't return multiple return values (1.doc before diff, 2. doc after diff). - * Made callback protected to be able to override it with public visibility in TestableResourceEditor instead - * so that tests can call it directly. - */ - protected open fun onDiffClosed(resource: HasMetadata, documentBeforeDiff: String?) { + * Protected visibility for testing purposes: + * Tried capturing callback parameter in #diff and running it, but it didn't work. + * Document mock didn't return multiple return values (1.doc before diff, 2. doc after diff). + * Made callback protected to be able to override it with public visibility in TestableResourceEditor instead + * so that tests can call it directly. + */ + protected open fun onDiffClosed(documentBeforeDiff: String?) { val afterDiff = getDocument.invoke(editor)?.text val modified = (documentBeforeDiff != afterDiff) if (modified) { - saveResourceVersion(resource) update() } } fun startWatch(): ResourceEditor { - clusterResource?.watch() + attributes.getAllClusterResources().forEach { clusterResource -> clusterResource.watch() } return this } fun stopWatch() { - // use backing variable to prevent accidental creation - _clusterResource?.stopWatch() + attributes.getAllClusterResources() + .forEach { clusterResource -> clusterResource.stopWatch() } } fun removeClutter() { - val resource = editorResource.get() ?: return - MetadataClutter.remove(resource.metadata) - replaceDocument(resource) + resourceChangeMutex.withLock { + editorResources.forEach { resource -> MetadataClutter.remove(resource.metadata) } + } + replaceDocument() runInUI { hideNotifications() } } - private fun createClusterResource(resource: HasMetadata?, context: IActiveContext?): ClusterResource? { - val clusterResource = clusterResourceFactory.invoke(resource, context) ?: return null - clusterResource.addListener(onResourceChanged()) - clusterResource.watch() - return clusterResource + /** + * Returns `true` if this editor is editing the given resource. + * Returns `false` otherwise. + * + * @param resource the resource to check if it's being edited by this editor + * @return true if this editor is editing the given resource + */ + fun isEditing(resource: HasMetadata): Boolean { + return editorResources.find { editorResource -> + resource.isSameResource(editorResource) + } != null } private fun onResourceChanged(): IResourceModelListener { @@ -426,7 +499,7 @@ open class ResourceEditor( } override fun removed(removed: Any) { - update(true) + update() } override fun modified(modified: Any) { @@ -435,20 +508,36 @@ open class ResourceEditor( } } + private fun onNamespaceOrContextChanged(): IResourceModelListener { + return object : IResourceModelListener { + override fun modified(modified: Any) { + if (modified is IResourceModel) { + // active context changed, recreate cluster resource + attributes.disposeAll() + update() + } + } + + override fun currentNamespaceChanged(new: IActiveContext<*,*>?, old: IActiveContext<*,*>?) { + // current namespace in same context has changed, recreate cluster resource + attributes.disposeAll() + update() + } + } + } /** * Closes this instance and cleans up references to it. * - Removes the resource model listener, - * - closes the [clusterResource], + * - closes all [attributes], * - removes the references in editor- and editor file-userdata * - saves the resource version */ fun close() { - resourceModel.removeListener(this) + resourceModel.removeListener(onNamespaceContextChanged) // use backing variable to prevent accidental creation - _clusterResource?.close() + attributes.dispose() editor.putUserData(KEY_RESOURCE_EDITOR, null) editor.file?.putUserData(KEY_RESOURCE_EDITOR, null) - resourceVersion.save() } /** @@ -459,7 +548,7 @@ open class ResourceEditor( if (editor.file == null || !isKubernetesResource( getKubernetesResourceInfo.invoke(editor.file, project))){ - return + return } createResourceFileForVirtual(editor.file)?.enableNonProjectFileEditing() } @@ -472,39 +561,6 @@ open class ResourceEditor( } } - override fun modified(modified: Any) { - if (modified is IResourceModel) { - // active context changed, recreate cluster resource - recreateClusterResource() - resourceVersion.set(null) - update() - } - } - - override fun currentNamespaceChanged(new: IActiveContext<*,*>?, old: IActiveContext<*,*>?) { - // current namespace in same context has changed, recreate cluster resource - recreateClusterResource() - resourceVersion.set(null) - update() - } - - private fun saveResourceVersion(resource: HasMetadata?) { - val version = resource?.metadata?.resourceVersion ?: return - resourceVersion.set(version) - } - - /** - * Closes the current [clusterResource] so that a new one is created when it is accessed (ex. in [update]). - * - * @see clusterResource - */ - private fun recreateClusterResource() { - resourceChangeMutex.withLock { - clusterResource?.close() - clusterResource// accessing causes re-creation - } - } - /** for testing purposes */ protected open fun runAsync(runnable: () -> Unit) { ApplicationManager.getApplication().executeOnPooledThread(runnable) @@ -530,6 +586,7 @@ open class ResourceEditor( } override fun dispose() { - resourceModel.removeListener(this) + resourceModel.removeListener(onNamespaceContextChanged) + attributes.dispose() } } diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditorFactory.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditorFactory.kt index 0b8d4f92e..b84add0dc 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditorFactory.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditorFactory.kt @@ -24,7 +24,6 @@ import com.redhat.devtools.intellij.kubernetes.editor.util.getKubernetesResource import com.redhat.devtools.intellij.kubernetes.editor.util.hasKubernetesResource import com.redhat.devtools.intellij.kubernetes.model.IResourceModel import com.redhat.devtools.intellij.kubernetes.model.util.ResourceException -import com.redhat.devtools.intellij.kubernetes.model.util.isSameResource import com.redhat.devtools.intellij.kubernetes.telemetry.TelemetryService import com.redhat.devtools.intellij.telemetry.core.service.TelemetryMessageBuilder import io.fabric8.kubernetes.api.model.HasMetadata @@ -99,11 +98,12 @@ open class ResourceEditorFactory protected constructor( private fun getExisting(resource: HasMetadata, project: Project): ResourceEditor? { return getFileEditorManager.invoke(project).allEditors - .mapNotNull { editor -> getExisting(editor) } + .mapNotNull { editor -> + getExisting(editor) } .firstOrNull { resourceEditor -> // get editor for a temporary file thus only editors for temporary files are candidates isTemporary.invoke(resourceEditor.editor.file) - && resource.isSameResource(resourceEditor.editorResource.get()) + && resourceEditor.isEditing(resource) } } @@ -115,8 +115,7 @@ open class ResourceEditorFactory protected constructor( */ fun getExistingOrCreate(editor: FileEditor?, project: Project?): ResourceEditor? { if (editor == null - || project == null - ) { + || project == null) { return null } diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/actions/DiffAction.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/actions/DiffAction.kt index 4e3f6b3bb..0afd1a279 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/actions/DiffAction.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/actions/DiffAction.kt @@ -33,7 +33,7 @@ class DiffAction: AnAction() { try { val editor = ResourceEditorFactory.instance.getExistingOrCreate(fileEditor, project) ?: return@Progressive editor.diff() - TelemetryService.sendTelemetry(editor.editorResource.get(), telemetry) + TelemetryService.sendTelemetry(editor.editorResources, telemetry) } catch (e: Exception) { Notification().error("Error showing diff", "Could not show diff editor vs resource from cluster: ${e.message}") telemetry.error(e).send() diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/actions/PullAction.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/actions/PullAction.kt index 535fb1924..23443b32f 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/actions/PullAction.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/actions/PullAction.kt @@ -37,7 +37,7 @@ class PullAction: AnAction() { try { val editor = ResourceEditorFactory.instance.getExistingOrCreate(fileEditor, project) ?: return@Progressive editor.pull() - sendTelemetry(editor.editorResource.get(), telemetry) + sendTelemetry(editor.editorResources, telemetry) } catch (e: Exception) { Notification().error("Error Pulling", "Could not pull resource from cluster: ${e.message}") telemetry.error(e).send() diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/actions/PushAction.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/actions/PushAction.kt index 84a8c9e5c..e48a85d3a 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/actions/PushAction.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/actions/PushAction.kt @@ -38,7 +38,7 @@ class PushAction: AnAction() { try { val resourceEditor = ResourceEditorFactory.instance.getExistingOrCreate(editor, project) ?: return@Progressive resourceEditor.push() - sendTelemetry(resourceEditor.editorResource.get(), telemetry) + sendTelemetry(resourceEditor.editorResources, telemetry) } catch (e: Exception) { logger().warn("Could not push resource to cluster: ${e.message}", e) Notification().error("Error Pushing", "Could not push resource to cluster: ${e.message}") diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/actions/RemoveClutterAction.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/actions/RemoveClutterAction.kt index f411660c5..7fb59a950 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/actions/RemoveClutterAction.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/actions/RemoveClutterAction.kt @@ -33,7 +33,7 @@ class RemoveClutterAction: AnAction() { try { val editor = ResourceEditorFactory.instance.getExistingOrCreate(fileEditor, project) ?: return@Progressive editor.removeClutter() - TelemetryService.sendTelemetry(editor.editorResource.get(), telemetry) + TelemetryService.sendTelemetry(editor.editorResources, telemetry) } catch (e: Exception) { Notification().error("Error removing metadata clutter", "Could not remove metadata clutter: ${e.message}") telemetry.error(e).send() diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/DeletedNotification.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/DeletedNotification.kt index a6a71ded2..fb89cce04 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/DeletedNotification.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/DeletedNotification.kt @@ -40,7 +40,7 @@ class DeletedNotification(private val editor: FileEditor, private val project: P val panel = EditorNotificationPanel() panel.setText("${resource.kind} '${resource.metadata.name}' was deleted on cluster. Push to Cluster?") addPush(panel) - addIgnore(panel) { + addDismiss(panel) { hide() } diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/ErrorNotification.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/ErrorNotification.kt index af654158c..8e5007216 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/ErrorNotification.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/ErrorNotification.kt @@ -47,6 +47,9 @@ class ErrorNotification(private val editor: FileEditor, private val project: Pro panel.icon(AllIcons.Ide.FatalError) panel.text = title addDetailsAction(message, panel, editor) + addDismiss(panel) { + hide() + } return panel } diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/NotificationActions.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/NotificationActions.kt index ce428d21b..235f56127 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/NotificationActions.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/NotificationActions.kt @@ -13,8 +13,8 @@ fun addPull(panel: EditorNotificationPanel) { panel.createActionLabel("Pull", PullAction.ID) } -fun addIgnore(panel: EditorNotificationPanel, consumer: () -> Unit) { - panel.createActionLabel("Ignore", consumer) +fun addDismiss(panel: EditorNotificationPanel, consumer: () -> Unit) { + panel.createActionLabel("Dismiss", consumer) } fun addDiff(panel: EditorNotificationPanel) { diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/PullNotification.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/PullNotification.kt index f0d719285..3864d426e 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/PullNotification.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/PullNotification.kt @@ -29,23 +29,21 @@ class PullNotification(private val editor: FileEditor, private val project: Proj val KEY_PANEL = Key(PullNotification::class.java.canonicalName) } - fun show(resource: HasMetadata, canPush: Boolean) { - editor.showNotification(KEY_PANEL, { createPanel(resource, canPush) }, project) + fun show(resource: HasMetadata) { + editor.showNotification(KEY_PANEL, { createPanel(resource) }, project) } fun hide() { editor.hideNotification(KEY_PANEL, project) } - private fun createPanel(resource: HasMetadata, canPush: Boolean): EditorNotificationPanel { + private fun createPanel(resource: HasMetadata): EditorNotificationPanel { val panel = EditorNotificationPanel() - panel.setText("${resource.kind} '${resource.metadata.name}' changed on cluster. Pull?") + panel.text = "${resource.kind} '${resource.metadata.name}' changed on cluster. Pull?" addPull(panel) - if (canPush) { - addPush(panel) - } + addPush(panel) addDiff(panel) - addIgnore(panel) { + addDismiss(panel) { hide() } return panel diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/PulledNotification.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/PulledNotification.kt index b6edd42a4..b2a86e56e 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/PulledNotification.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/PulledNotification.kt @@ -38,16 +38,15 @@ class PulledNotification(private val editor: FileEditor, private val project: Pr private fun createPanel(resource: HasMetadata): EditorNotificationPanel { val panel = EditorNotificationPanel() - panel.setText( - "Pulled changed ${resource.kind} '${resource.metadata.name}' ${ + panel.text = + "Pulled ${resource.kind} '${resource.metadata.name}' ${ if (resource.metadata.resourceVersion != null) { "to revision ${resource.metadata.resourceVersion}" } else { "" } }" - ) - addIgnore(panel) { + addDismiss(panel) { hide() } return panel diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/PushNotification.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/PushNotification.kt index 7e7745032..7c357f413 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/PushNotification.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/PushNotification.kt @@ -14,8 +14,10 @@ import com.intellij.openapi.fileEditor.FileEditor import com.intellij.openapi.project.Project import com.intellij.openapi.util.Key import com.intellij.ui.EditorNotificationPanel +import com.intellij.util.containers.isNullOrEmpty import com.redhat.devtools.intellij.kubernetes.editor.hideNotification import com.redhat.devtools.intellij.kubernetes.editor.showNotification +import com.redhat.devtools.intellij.kubernetes.model.util.toKindAndNames import javax.swing.JComponent /** @@ -27,25 +29,50 @@ class PushNotification(private val editor: FileEditor, private val project: Proj val KEY_PANEL = Key(PushNotification::class.java.canonicalName) } - fun show(existsOnCluster: Boolean, isOutdated: Boolean) { - editor.showNotification(KEY_PANEL, { createPanel(existsOnCluster, isOutdated) }, project) + fun show(showPull: Boolean, states: Collection) { + if (states.isEmpty()) { + return + } + editor.showNotification(KEY_PANEL, { createPanel(showPull, states) }, project) } fun hide() { editor.hideNotification(KEY_PANEL, project) } - private fun createPanel(existsOnCluster: Boolean, isOutdated: Boolean): EditorNotificationPanel { - val panel = EditorNotificationPanel() - panel.setText( - "Push local changes, ${ - if (!existsOnCluster) { - "create new" + private fun createPanel(showPull: Boolean, states: Collection): EditorNotificationPanel { + val toCreateOrUpdate = states.groupBy { state -> + state.exists + } + val toCreate = toCreateOrUpdate[false] + val toUpdate = toCreateOrUpdate[true] + val text = createText(toCreate, toUpdate) + return createPanel(text, + true == toUpdate?.isNotEmpty(), + showPull && states.any { state -> state.isOutdatedVersion}) + } + + private fun createText(toCreate: List?, toUpdate: List?): String { + return StringBuilder().apply { + if (!toCreate.isNullOrEmpty()) { + append("Push to create ${toKindAndNames(toCreate?.map { state -> state.resource })}") + } + if (!toUpdate.isNullOrEmpty()) { + if (isNotEmpty()) { + append(", ") } else { - "update existing" + append("Push to ") } - } resource on cluster?" - ) + append("update ${toKindAndNames(toUpdate?.map { state -> state.resource })}") + } + append("?") + } + .toString() + } + + private fun createPanel(text: String, existsOnCluster: Boolean, isOutdated: Boolean): EditorNotificationPanel { + val panel = EditorNotificationPanel() + panel.text = text addPush(panel) if (isOutdated) { addPull(panel) @@ -53,7 +80,7 @@ class PushNotification(private val editor: FileEditor, private val project: Proj if (existsOnCluster) { addDiff(panel) } - addIgnore(panel) { + addDismiss(panel) { hide() } diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/PushedNotification.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/PushedNotification.kt new file mode 100644 index 000000000..d11d5b41f --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/PushedNotification.kt @@ -0,0 +1,72 @@ +/******************************************************************************* + * Copyright (c) 2023 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.kubernetes.editor.notification + +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.ui.EditorNotificationPanel +import com.intellij.util.containers.isNullOrEmpty +import com.redhat.devtools.intellij.kubernetes.editor.hideNotification +import com.redhat.devtools.intellij.kubernetes.editor.showNotification +import com.redhat.devtools.intellij.kubernetes.model.util.toKindAndNames +import javax.swing.JComponent + +/** + * An editor (panel) notification that informs that the editor was pushed to the cluster. + */ +class PushedNotification(private val editor: FileEditor, private val project: Project) { + + companion object { + val KEY_PANEL = Key(PushedNotification::class.java.canonicalName) + } + + fun show(states: Collection) { + if (states.isEmpty()) { + return + } + editor.showNotification(KEY_PANEL, { createPanel(states) }, project) + } + + fun hide() { + editor.hideNotification(KEY_PANEL, project) + } + + private fun createPanel(states: Collection): EditorNotificationPanel { + val panel = EditorNotificationPanel() + val toCreateOrUpdate = states.groupBy { state -> + state.exists + } + val toCreate = toCreateOrUpdate[false] + val toUpdate = toCreateOrUpdate[true] + panel.text = createText(toCreate, toUpdate) + addDismiss(panel) { + hide() + } + + return panel + } + + private fun createText(created: List?, updated: List?): String { + return StringBuilder().apply { + if (!created.isNullOrEmpty()) { + append("Created ${toKindAndNames(created?.map { state -> state.resource })} ") + } + if (!updated.isNullOrEmpty()) { + if (isNotEmpty()) { + append(", ") + } + append("updated ${toKindAndNames(updated?.map { state -> state.resource })}") + } + } + .toString() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/ResourceState.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/ResourceState.kt new file mode 100644 index 000000000..a591f28df --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/editor/notification/ResourceState.kt @@ -0,0 +1,40 @@ + +/******************************************************************************* + * Copyright (c) 2023 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.kubernetes.editor.notification + +import io.fabric8.kubernetes.api.model.HasMetadata + +abstract class ResourceState(val resource: HasMetadata) + +abstract class Different(resource: HasMetadata, val exists: Boolean, val isOutdatedVersion: Boolean): ResourceState(resource) { + abstract fun isPush(): Boolean +} + +class Error(resource: HasMetadata, val title: String, val message: String? = null): ResourceState(resource) { + constructor(resource: HasMetadata, title: String, e: Throwable? = null) : this(resource, title, e?.message) +} + +class Identical(resource: HasMetadata): ResourceState(resource) + +open class Modified(resource: HasMetadata, exists: Boolean, isOutdatedVersion: Boolean): Different(resource, exists, isOutdatedVersion) { + override fun isPush() = true +} + +class DeletedOnCluster(resource: HasMetadata): Modified(resource, false, false) { + override fun isPush() = true + +} + +class Outdated(resource: HasMetadata): Different(resource, true, true) { + override fun isPush() = false + +} diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/ResourceWatch.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/ResourceWatch.kt index 2516aa89b..9d9081fa1 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/ResourceWatch.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/ResourceWatch.kt @@ -36,7 +36,7 @@ open class ResourceWatch( } protected open val watches: ConcurrentHashMap = ConcurrentHashMap() - private val executor: ExecutorService = Executors.newWorkStealingPool(10) + private val executor: ExecutorService = Executors.newWorkStealingPool(20) private val thread = executor.submit(watchOperationsRunner) open fun watchAll( diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/resource/ResourceIdentifier.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/resource/ResourceIdentifier.kt new file mode 100644 index 000000000..0fdc379d9 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/resource/ResourceIdentifier.kt @@ -0,0 +1,69 @@ +/******************************************************************************* + * Copyright (c) 2022 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.kubernetes.model.resource + +import io.fabric8.kubernetes.api.model.HasMetadata + +class ResourceIdentifier(val resource: HasMetadata) { + + val name: String? + get() { + return resource.metadata.name + } + + val generateName: String? + get() { + return resource.metadata.generateName + } + + val namespace: String + get() { + return resource.metadata.namespace + } + + private val resourceKind by lazy { + ResourceKind.create(resource) + } + + val kind: String + get() { + return resourceKind.kind + } + val version: String + get() { + return resourceKind.version + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ResourceIdentifier + + if (name != other.name) return false + if (generateName != other.generateName) return false + if (namespace != other.namespace) return false + if (kind != other.kind) return false + if (version != other.version) return false + + return true + } + + override fun hashCode(): Int { + var result = name?.hashCode() ?: 0 + result = 31 * result + (generateName?.hashCode() ?: 0) + result = 31 * result + namespace.hashCode() + result = 31 * result + kind.hashCode() + result = 31 * result + version.hashCode() + return result + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/util/ResourceUtils.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/util/ResourceUtils.kt index 2fbe6fe6f..81e477cab 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/util/ResourceUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/util/ResourceUtils.kt @@ -25,7 +25,7 @@ const val MARKER_WILL_BE_DELETED = "willBeDeleted" const val API_GROUP_VERSION_DELIMITER = '/' /** - * returns `true` if the given resource has the same + * Returns `true` if the given resource has the same * - kind * - apiVersion * - name @@ -257,7 +257,7 @@ fun getHighestPriorityVersion(spec: CustomResourceDefinitionSpec): String? { * @param jsonYaml the string that should be unmarshalled * @return the instance of the given type */ -inline fun createResource(jsonYaml: String): T { +inline fun createResource(jsonYaml: String): T { return Serialization.unmarshal(jsonYaml, T::class.java) } @@ -276,6 +276,16 @@ fun runWithoutServerSetProperties(resource: R, operation: () - return value } +/** + * Runs the given operation removing the server set properties from given [thisResource] and [thatResource]. + * Returns the result of the given operation. + * + * The following properties are set to `null` and restored once the operation was run: + * * [io.fabric8.kubernetes.api.model.ObjectMeta.resourceVersion] + * * [io.fabric8.kubernetes.api.model.ObjectMeta.uid] + * + * @return true if the resource is not equal to the same resource on the cluster + */ fun runWithoutServerSetProperties(thisResource: T?, thatResource: T?, operation: () -> R): R { // remove properties val thisResourceVersion = removeResourceVersion(thisResource) @@ -344,3 +354,50 @@ fun hasGenerateName(resource: R): Boolean { fun hasName(resource: R): Boolean { return true == resource.metadata.name?.isNotEmpty() } + + +/** + * Returns a string stating kinds and names for the given resources. + * + * ex. 'Pod luke skywalker, Deployment rebel base' + */ +fun toNames(resources: Collection?): String? { + return toString(::toName, resources) +} + +fun toKindAndNames(resources: Collection?): String? { + return toString(::toKindAndName, resources) +} + +fun toString(toString: (resource: HasMetadata) -> String, resources: Collection?): String? { + if (resources == null) { + return null + } + return if (resources.isEmpty()) { + return null + } else { + resources.joinToString { resource -> + toString.invoke(resource) + } + } +} + +/** + * Returns `true` if the 2 given resources are equal. Returns `false` otherwise. + * The following properties are not taken into account: + * * [io.fabric8.kubernetes.api.model.ObjectMeta.resourceVersion] + * * [io.fabric8.kubernetes.api.model.ObjectMeta.uid] + * + * @return true if the resource is not equal to the same resource on the cluster + */ +fun areEqual(thisResource: HasMetadata?, thatResource: HasMetadata?): Boolean { + return runWithoutServerSetProperties(thisResource, thatResource) { + thisResource == thatResource + } +} + +private fun toName(resource: HasMetadata) = + resource.metadata.name ?: resource.metadata.generateName + +private fun toKindAndName(resource: HasMetadata) = + "${resource.kind} '${resource.metadata.name ?: resource.metadata.generateName}'" diff --git a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/telemetry/TelemetryService.kt b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/telemetry/TelemetryService.kt index d947a4d18..3c91855a6 100644 --- a/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/telemetry/TelemetryService.kt +++ b/src/main/kotlin/com/redhat/devtools/intellij/kubernetes/telemetry/TelemetryService.kt @@ -32,6 +32,10 @@ object TelemetryService { TelemetryMessageBuilder(TelemetryService::class.java.classLoader) } + fun sendTelemetry(resources: Collection, telemetry: TelemetryMessageBuilder.ActionMessage) { + telemetry.property(PROP_RESOURCE_KIND, getKinds(resources)).send() + } + fun sendTelemetry(resource: HasMetadata?, telemetry: TelemetryMessageBuilder.ActionMessage) { sendTelemetry(getResourceKind(resource), telemetry) } @@ -53,7 +57,7 @@ object TelemetryService { .joinToString() } - fun kindOrUnknown(kind: ResourceKind<*>?): String { + private fun kindOrUnknown(kind: ResourceKind<*>?): String { return if (kind != null) { "${kind.version}/${kind.kind}" } else { @@ -61,7 +65,7 @@ object TelemetryService { } } - fun kindOrUnknown(info: KubernetesTypeInfo?): String { + private fun kindOrUnknown(info: KubernetesTypeInfo?): String { return if (info != null) { "${info.apiGroup}/${info.kind}" } else { diff --git a/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ClusterResourceTest.kt b/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ClusterResourceTest.kt index d5492f81c..b8a03cb5b 100644 --- a/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ClusterResourceTest.kt +++ b/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ClusterResourceTest.kt @@ -111,54 +111,6 @@ class ClusterResourceTest { // then should have thrown } - @Test - fun `#canPush should return true if cluster has no resource`() { - // given - whenever(context.get(any())) - .doReturn(null) - // when - val canPush = cluster.canPush(endorResource) - // then - assertThat(canPush).isTrue() - } - - @Test - fun `#canPush should return true if given resource is null`() { - // given - // when - val canPush = cluster.canPush(null) - // then - assertThat(canPush).isTrue() - } - - @Test - fun `#canPush should return true if given resource is modified`() { - // given - val modifiedResource = PodBuilder(endorResourceOnCluster) - .editOrNewMetadata() - .withResourceVersion("resourceVersion-42") - .endMetadata() - .build() - // when - val canPush = cluster.canPush(modifiedResource) - // then - assertThat(canPush).isTrue() - } - - @Test - fun `#canPush should return false if given resource has different name`() { - // given - val differentName = PodBuilder(endorResourceOnCluster) - .editOrNewMetadata() - .withName("name-42") - .endMetadata() - .build() - // when - val canPush = cluster.canPush(differentName) - // then - assertThat(canPush).isFalse() - } - @Test fun `#push should replace if exists on cluster`() { // given @@ -170,44 +122,6 @@ class ClusterResourceTest { verify(context).replace(endorResourceOnCluster) } - @Test - fun `#canPush should return false if given resource has different namespace`() { - // given - val differentNamespace = PodBuilder(endorResource) - .editOrNewMetadata() - .withNamespace("namespace-42") - .endMetadata() - .build() - // when - val canPush = cluster.canPush(differentNamespace) - // then - assertThat(canPush).isFalse() - } - - @Test - fun `#canPush should return false if given resource has different kind`() { - // given - val differentKind = PodBuilder(endorResource) - .withKind("kind-42") - .build() - // when - val canPush = cluster.canPush(differentKind) - // then - assertThat(canPush).isFalse() - } - - @Test - fun `#canPush should return false if given resource has different apiVersion`() { - // given - val differentApiVersion = PodBuilder(endorResource) - .withApiVersion("apiVersion-42") - .build() - // when - val canPush = cluster.canPush(differentApiVersion) - // then - assertThat(canPush).isFalse() - } - @Test fun `#push should call operator#replace`() { // given @@ -311,7 +225,7 @@ class ClusterResourceTest { whenever(context.get(any())) .doReturn(null) // when - val outdated = cluster.isOutdated(resourceVersion) + val outdated = cluster.isOutdatedVersion(resourceVersion) // then assertThat(outdated).isFalse() } @@ -323,7 +237,7 @@ class ClusterResourceTest { whenever(context.get(any())) .doReturn(null) // when - val outdated = cluster.isOutdated(resourceVersion as String?) + val outdated = cluster.isOutdatedVersion(resourceVersion as String?) // then assertThat(outdated).isFalse() } @@ -335,7 +249,7 @@ class ClusterResourceTest { whenever(context.get(any())) .doReturn(endorResourceOnCluster) // when - val outdated = cluster.isOutdated(resourceVersion as String?) + val outdated = cluster.isOutdatedVersion(resourceVersion as String?) // then assertThat(outdated).isTrue() } @@ -347,7 +261,7 @@ class ClusterResourceTest { whenever(context.get(any())) .doReturn(endorResourceOnCluster) // when - val outdated = cluster.isOutdated(resourceVersion as String?) + val outdated = cluster.isOutdatedVersion(resourceVersion as String?) // then assertThat(outdated).isTrue() } @@ -359,7 +273,7 @@ class ClusterResourceTest { whenever(context.get(any())) .doReturn(endorResourceOnCluster) // when - val outdated = cluster.isOutdated(resourceVersion as String?) + val outdated = cluster.isOutdatedVersion(resourceVersion as String?) // then - resourceVersion is alphanumeric, no numeric comparison is possible // see https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions assertThat(outdated).isTrue() @@ -372,74 +286,11 @@ class ClusterResourceTest { whenever(context.get(any())) .doReturn(endorResourceOnCluster) // when - val outdated = cluster.isOutdated(resourceVersion) + val outdated = cluster.isOutdatedVersion(resourceVersion) // then assertThat(outdated).isFalse() } - @Test - fun `#isModified should return true if given resource has different kind`() { - // given - val modifiedResource = PodBuilder(endorResource) - .withKind("kind-42") - .build() - // when - val isModified = cluster.isModified(modifiedResource) - // then - assertThat(isModified).isTrue() - } - - @Test - fun `#isModified should return true if given resource has different apiVersion`() { - // given - val modifiedResource = PodBuilder(endorResource) - .withApiVersion("apiVersion-42") - .build() - // when - val isModified = cluster.isModified(modifiedResource) - // then - assertThat(isModified).isTrue() - } - - @Test - fun `#isModified should return true if given resource has different namespace`() { - // given - val modifiedResource = PodBuilder(endorResource) - .editOrNewMetadata() - .withNamespace("name-42") - .endMetadata() - .build() - // when - val isModified = cluster.isModified(modifiedResource) - // then - assertThat(isModified).isTrue() - } - - @Test - fun `#isModified should return true if given resource has different name`() { - // given - val modifiedResource = PodBuilder(endorResource) - .editOrNewMetadata() - .withName("name-42") - .endMetadata() - .build() - // when - val isModified = cluster.isModified(modifiedResource) - // then - assertThat(isModified).isTrue() - } - - @Test - fun `#isModified should return false if doesn't exist on cluster`() { - // given - whenever(context.get(any())) - .doReturn(null) - // when - val isModified = cluster.isModified(endorResource) - // then - assertThat(isModified).isFalse() - } - @Test fun `#exists should return false if resource retrieval has 404`() { // given @@ -616,6 +467,5 @@ class ClusterResourceTest { public override fun set(resource: HasMetadata?) { super.set(resource) } - } } \ No newline at end of file diff --git a/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditorAttributesTest.kt b/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditorAttributesTest.kt new file mode 100644 index 000000000..d031ebc85 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditorAttributesTest.kt @@ -0,0 +1,175 @@ +/******************************************************************************* + * Copyright (c) 2023 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.kubernetes.editor + +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import com.redhat.devtools.intellij.kubernetes.model.IResourceModel +import com.redhat.devtools.intellij.kubernetes.model.context.IActiveContext +import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks +import io.fabric8.kubernetes.api.model.HasMetadata +import io.fabric8.kubernetes.api.model.Pod +import io.fabric8.kubernetes.api.model.PodBuilder +import io.fabric8.kubernetes.client.KubernetesClient +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Test + +class ResourceEditorAttributesTest { + + private val context: IActiveContext<*,*> = mock() + private val model: IResourceModel = mock { + on { getCurrentContext() } doReturn context + } + private val clusterResource: ClusterResource = mock() + private val createClusterResource: (resource: HasMetadata, context: IActiveContext?) -> ClusterResource? = + mock { + on { invoke(any(), any()) } doReturn clusterResource + } + private val attributes = EditorResourceAttributes(model, createClusterResource) + + @After + fun after() { + attributes.disposeAll() + } + + @Test + fun `#update should create clusterResource for given resource`() { + // given + val resource: HasMetadata = ClientMocks.POD2 + // when + attributes.update(listOf(resource)) // create attribute for resource + // then + verify(createClusterResource).invoke(resource, context) + } + + @Test + fun `#update should watch clusterResource for given resource`() { + // given + val resource: HasMetadata = ClientMocks.POD2 + // when + attributes.update(listOf(resource)) // create attribute for resource + // then + verify(clusterResource).watch() + } + + @Test + fun `#update should close existing clusterResource if new resource is given in #update`() { + // given + val existing: HasMetadata = ClientMocks.POD2 + val new: HasMetadata = ClientMocks.POD3 + attributes.update(listOf(existing)) // create attribute for resource + // when + attributes.update(listOf(new)) // create new attribute, close existing cluster resource + // then + verify(clusterResource).close() + } + + @Test + fun `#update should watch new clusterResource if #update contains additional resource`() { + // given + val initial: HasMetadata = ClientMocks.POD2 + val initialClusterResource: ClusterResource = mock() + doReturn(initialClusterResource) + .whenever(createClusterResource).invoke(eq(initial), any()) + attributes.update(listOf(initial)) // create attribute for resource + verify(initialClusterResource).watch() + + val additional: HasMetadata = ClientMocks.POD3 + val additionalClusterResource: ClusterResource = mock() + doReturn(additionalClusterResource) + .whenever(createClusterResource).invoke(eq(additional), any()) + // when + attributes.update(listOf(initial, ClientMocks.POD3)) + // then + verify(additionalClusterResource).watch() + } + + @Test + fun `#update should NOT close existing clusterResource if existing resource is given again in #update`() { + // given + val existing: HasMetadata = ClientMocks.POD2 + attributes.update(listOf(existing)) // create attribute for resource + // when + attributes.update(listOf(existing)) // create new attribute, close existing cluster resource + // then + verify(clusterResource, never()).close() + } + + @Test + fun `#getClusterResource(HasMetadata) should return existing cluster resource for given resource`() { + // given + val resource: HasMetadata = ClientMocks.POD2 + attributes.update(listOf(resource)) // create attribute for resource + // when + val clusterResource = attributes.getClusterResource(resource) + // then + assertThat(clusterResource).isNotNull + } + + @Test + fun `#getClusterResource(HasMetadata) should return null if given resource wasn't given in #update()`() { + // given + val resource: HasMetadata = ClientMocks.POD2 + attributes.update(listOf(resource)) // create attribute for resource + // when + val clusterResource = attributes.getClusterResource(ClientMocks.POD3) + // then + assertThat(clusterResource).isNull() + } + + @Test + fun `#getResourceVersion(HasMetadata) should return resourceVersion of given resource`() { + // given + val resource: HasMetadata = ClientMocks.POD2 + attributes.update(listOf(resource)) // create attribute for resource + // when + val resourceVersion = attributes.getResourceVersion(resource) + // then + assertThat(resourceVersion).isEqualTo(resource.metadata.resourceVersion) + } + + @Test + fun `#getLastPulledPushed(HasMetadata) should return resource that was given in #update`() { + // given + val resource: HasMetadata = ClientMocks.POD2 + attributes.update(listOf(resource)) // create attribute for resource + // when + val resourceVersion = attributes.getLastPulledPushed(resource) + // then + assertThat(resourceVersion).isEqualTo(resource) + } + + @Test + fun `#getLastPulledPushed(HasMetadata) should return resource that was set`() { + // given + val initial: Pod = ClientMocks.POD2 + attributes.update(listOf(initial)) // create attribute for resource + val new: HasMetadata = PodBuilder(initial) + // same kind, apiversion, name, namespace + .editMetadata() + .withLabels(mapOf("jedi" to "luke skywalker")) + .withResourceVersion("42") + .endMetadata() + .build() + attributes.setLastPushedPulled(new) + // when + val lastPulledPushed = attributes.getLastPulledPushed(initial) + // then + assertThat(lastPulledPushed).isEqualTo(new) + } + +} \ No newline at end of file diff --git a/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditorFactoryTest.kt b/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditorFactoryTest.kt index a9816c385..aa301642e 100644 --- a/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditorFactoryTest.kt +++ b/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditorFactoryTest.kt @@ -67,7 +67,7 @@ class ResourceEditorFactoryTest { key: secret.password """.trimIndent() - private val resource = createResource(deployment)!! + private val resource = createResource(deployment)!! private val virtualFile: VirtualFile = mock { on { isInLocalFileSystem } doReturn true } @@ -123,7 +123,7 @@ class ResourceEditorFactoryTest { private val getProjectManager: () -> ProjectManager = { projectManager } private val resourceEditor: ResourceEditor = mock { on { editor } doReturn fileEditor - on { editorResource } doReturn mock() + on { editorResources } doReturn mock() } private val editorFactory = diff --git a/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditorTest.kt b/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditorTest.kt index cc9f6b5ea..92377bceb 100644 --- a/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditorTest.kt +++ b/src/test/kotlin/com/redhat/devtools/intellij/kubernetes/editor/ResourceEditorTest.kt @@ -13,20 +13,24 @@ package com.redhat.devtools.intellij.kubernetes.editor import com.intellij.json.JsonFileType import com.intellij.openapi.editor.Document import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileTypes.FileType import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiDocumentManager import com.intellij.psi.PsiFile import com.intellij.psi.util.PsiUtilCore import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.anyOrNull import com.nhaarman.mockitokotlin2.argThat import com.nhaarman.mockitokotlin2.argWhere import com.nhaarman.mockitokotlin2.atLeastOnce import com.nhaarman.mockitokotlin2.clearInvocations import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.doThrow +import com.nhaarman.mockitokotlin2.eq import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.reset import com.nhaarman.mockitokotlin2.spy import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever @@ -37,14 +41,14 @@ import com.redhat.devtools.intellij.kubernetes.editor.notification.ErrorNotifica import com.redhat.devtools.intellij.kubernetes.editor.notification.PullNotification import com.redhat.devtools.intellij.kubernetes.editor.notification.PulledNotification import com.redhat.devtools.intellij.kubernetes.editor.notification.PushNotification +import com.redhat.devtools.intellij.kubernetes.editor.notification.PushedNotification import com.redhat.devtools.intellij.kubernetes.model.IResourceModel import com.redhat.devtools.intellij.kubernetes.model.context.IActiveContext -import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.POD2 import com.redhat.devtools.intellij.kubernetes.model.mocks.Mocks.kubernetesResourceInfo import com.redhat.devtools.intellij.kubernetes.model.mocks.Mocks.kubernetesTypeInfo -import com.redhat.devtools.intellij.kubernetes.model.util.ResettableLazyProperty import com.redhat.devtools.intellij.kubernetes.model.util.ResourceException import io.fabric8.kubernetes.api.model.HasMetadata +import io.fabric8.kubernetes.api.model.Pod import io.fabric8.kubernetes.api.model.PodBuilder import io.fabric8.kubernetes.client.KubernetesClient import io.fabric8.kubernetes.client.KubernetesClientException @@ -54,59 +58,48 @@ import org.assertj.core.api.Assertions.assertThat import org.jetbrains.yaml.YAMLFileType import org.junit.Before import org.junit.Test +import org.mockito.ArgumentMatcher @Suppress("UNUSED_ANONYMOUS_PARAMETER") class ResourceEditorTest { - private val job: String = """ -apiVersion: batch/v1 -kind: Job -metadata: - name: countdown -spec: - template: - metadata: - name: countdown - spec: - containers: - - name: counter - image: centos:7 - command: - - "bin/bash" - - "-c" - - "echo kube" - restartPolicy: Never -""" + private val NAMESPACE = "jedis" - // need real resources, not mocks - #equals used to track changes - private val GARGAMEL = PodBuilder() + private val GARGAMEL_INITIAL = PodBuilder() .withNewMetadata() .withName("Gargamel") - .withNamespace("namespace2") + .withNamespace("CastleBelvedere") .withResourceVersion("1") .endMetadata() - .withApiVersion("v1") + .withNewSpec() + .addNewContainer() + .withImage("thesmurfs") + .withName("thesmurfs") + .addNewPort() + .withContainerPort(8080) + .endPort() + .endContainer() + .endSpec() .build() + private val GARGAMEL_INITIAL_YAML = EditorResourceSerialization.serialize(listOf(GARGAMEL_INITIAL), YAMLFileType.YML) + // need real resources, not mocks - #equals used to track changes - private val GARGAMEL_WITH_LABEL = PodBuilder(GARGAMEL) + private val GARGAMEL_MODIFIED = PodBuilder(GARGAMEL_INITIAL) .editMetadata() .withLabels(mapOf(Pair("hat", "none"))) .endMetadata() .build() // need real resources, not mocks - #equals used to track changes - private val GARGAMELv2 = PodBuilder(GARGAMEL) + private val GARGAMEL_PUSHED_PULLED = PodBuilder(GARGAMEL_INITIAL) .editMetadata() .withResourceVersion("2") .endMetadata() .build() - private val AZRAEL = PodBuilder(GARGAMEL) - .editMetadata() - .withName("Azrael") - .endMetadata() - .build() - private val virtualFile: VirtualFile = mock() + private val virtualFile: VirtualFile = mock { + on { fileType } doReturn YAMLFileType.YML + } private val fileEditor: FileEditor = mock().apply { doReturn(virtualFile) .whenever(this).file @@ -123,30 +116,37 @@ spec: private val context: IActiveContext = mock() private val resourceModel: IResourceModel = mock { on { getCurrentContext() } doReturn context + on { getCurrentNamespace() } doReturn NAMESPACE } private val project: Project = mock() - private val createResource: (editor: FileEditor) -> HasMetadata? = - mock<(editor: FileEditor) -> HasMetadata?>().apply { - doReturn(GARGAMEL) - .whenever(this).invoke(any()) + private val createResources: (string: String?, fileType: FileType?, currentNamespace: String?) -> List = + mock<(string: String?, fileType: FileType?, currentNamespace: String?) -> List>().apply { + doReturn(listOf(GARGAMEL_INITIAL)) + .whenever(this).invoke(any(), any(), any()) } private val clusterResource: ClusterResource = mock { - on { pull(any()) } doReturn GARGAMELv2 + on { pull(any()) } doReturn GARGAMEL_PUSHED_PULLED + on { push(any()) } doReturn GARGAMEL_PUSHED_PULLED on { isSameResource(any()) } doReturn true } - private val clusterResourceFactory: (resource: HasMetadata?, context: IActiveContext?) -> ClusterResource? = - mock<(HasMetadata?, IActiveContext?) -> ClusterResource?>().apply { - doReturn(clusterResource) + private val attributes: EditorResourceAttributes = mock { + on { getClusterResource(any()) } doReturn clusterResource + on { getAllClusterResources() } doReturn listOf(clusterResource) + } + private val serialize: (resources: Collection, fileType: FileType?) -> String? = + mock<(resources: Collection, fileType: FileType?) -> String?>().apply { + doReturn(Serialization.asYaml(GARGAMEL_MODIFIED)) .whenever(this).invoke(any(), any()) } private val pushNotification: PushNotification = mock() + private val pushedNotification: PushedNotification = mock() private val pullNotification: PullNotification = mock() private val pulledNotification: PulledNotification = mock() private val deletedNotification: DeletedNotification = mock() private val errorNotification: ErrorNotification = mock() private val document: Document = mock().apply { - doReturn(job) - .whenever(this).getText() + doReturn(GARGAMEL_INITIAL_YAML) + .whenever(this).text } private val getDocument: (FileEditor) -> Document? = { document } // using a mock of PsiFile made tests fail with a NoClassDefFoundError on github @@ -154,14 +154,13 @@ spec: private val psiFile: PsiFile = spy(PsiUtilCore.NULL_PSI_FILE) private val psiDocumentManager: PsiDocumentManager = mock() private val getPsiDocumentManager: (Project) -> PsiDocumentManager = { psiDocumentManager } - private val kubernetesTypeInfo: KubernetesTypeInfo = kubernetesTypeInfo(GARGAMEL.kind, GARGAMEL.apiVersion) + private val kubernetesTypeInfo: KubernetesTypeInfo = kubernetesTypeInfo(GARGAMEL_INITIAL.kind, GARGAMEL_INITIAL.apiVersion) private val kubernetesResourceInfo: KubernetesResourceInfo = - kubernetesResourceInfo(GARGAMEL.metadata.name, GARGAMEL.metadata.namespace, kubernetesTypeInfo) + kubernetesResourceInfo(GARGAMEL_INITIAL.metadata.name, GARGAMEL_INITIAL.metadata.namespace, kubernetesTypeInfo) private val getKubernetesResourceInfo: (VirtualFile?, Project) -> KubernetesResourceInfo = { file, project -> kubernetesResourceInfo } private val documentReplaced: AtomicBoolean = AtomicBoolean(false) - private val resourceVersion: PersistentEditorValue = mock() private val diff: ResourceDiff = mock() private val editor = @@ -169,10 +168,11 @@ spec: fileEditor, resourceModel, project, - createResource, - clusterResourceFactory, + createResources, + serialize, createResourceFileForVirtual, pushNotification, + pushedNotification, pullNotification, pulledNotification, deletedNotification, @@ -181,8 +181,8 @@ spec: getPsiDocumentManager, getKubernetesResourceInfo, documentReplaced, - resourceVersion, - diff + diff, + attributes ) @Before @@ -191,6 +191,7 @@ spec: .whenever(psiFile).fileType doReturn(psiFile) .whenever(psiDocumentManager).getPsiFile(any()) + editor.initEditorResources(document) } @Test @@ -198,267 +199,327 @@ spec: // given // when // then - verify(resourceModel).addListener(editor) + verify(resourceModel).addListener(any()) } @Test - fun `#isModified should return true if editor resource doesn't exist on cluster`() { + fun `#dispose should stop listening to resource model`() { // given - doReturn(false) - .whenever(clusterResource).exists() // when - val modified = editor.isModified() + editor.dispose() // then - assertThat(modified).isTrue() + verify(resourceModel).removeListener(any()) } @Test - fun `#isModified should return false if editor resource exists on cluster`() { + fun `#update should hide all notifications when resource on cluster is deleted`() { // given - // lastPulledPushed is initialized with editorResource if resource exists on cluster - doReturn(true) - .whenever(clusterResource).exists() + givenClusterIsDeleted(true) + givenClusterExists(true) + givenEditorIsModified(false) + givenEditorIsOutdated(false) // when - val modified = editor.isModified() + editor.update() // then - assertThat(modified).isFalse() + verifyHideAllNotifications() } @Test - fun `#isModified should return true if editor resource is changed when compared to pushed resource`() { + fun `#update should hide all notifications when resource is modified`() { // given - editor.editorResource.set(GARGAMEL_WITH_LABEL) - editor.lastPushedPulled.set(GARGAMEL) + givenClusterIsDeleted(false) + givenClusterExists(true) + givenEditorIsModified(true) + givenEditorIsOutdated(false) // when - val modified = editor.isModified() + editor.update() // then - assertThat(modified).isTrue() + verifyHideAllNotifications() } @Test - fun `#isModified should return false after changed resource is pushed`() { + fun `#update should hide all notifications when resource is outdated`() { // given - editor.editorResource.set(GARGAMEL_WITH_LABEL) - doReturn(GARGAMEL_WITH_LABEL) - .whenever(createResource).invoke(any()) // called after pushing - editor.lastPushedPulled.set(GARGAMEL) - assertThat(editor.isModified()).isTrue() + givenClusterIsDeleted(false) + givenClusterExists(true) + givenEditorIsModified(false) + givenEditorIsOutdated(true) // when - editor.push() + editor.update() // then - assertThat(editor.isModified()).isFalse() + verifyHideAllNotifications() } + @Test - fun `#isModified should return false after changed resource is pulled`() { + fun `#update should hide all notifications when editor resource is NOT modified NOR outdated, NOR`() { // given - editor.editorResource.set(GARGAMEL_WITH_LABEL) - editor.lastPushedPulled.set(GARGAMEL) - assertThat(editor.isModified()).isTrue() + givenClusterIsDeleted(false) + givenClusterExists(true) + givenEditorIsModified(false) + givenEditorIsOutdated(false) // when - editor.pull() + editor.update() // then - assertThat(editor.isModified()).isFalse() + verifyHideAllNotifications() } @Test - fun `#update should hide all notifications when resource on cluster is deleted`() { + fun `#update should show error notification and hide all notifications if creating editor resource throws ResourceException`() { // given - givenEditorResourceIsModified(false) - givenEditorResourceIsOutdated(false) + doThrow(ResourceException("resource error", KubernetesClientException("client error"))) + .whenever(createResources).invoke(any(), any(), any()) // when - editor.update(true) + editor.update() // then + verify(errorNotification).show(any(), argWhere { it.contains("client error") }) verifyHideAllNotifications() } @Test - fun `#update should hide all notifications when resource is modified`() { + fun `#update should show deleted notification if resource on cluster is deleted`() { // given - givenEditorResourceIsModified(true) - givenEditorResourceIsOutdated(false) + givenClusterIsDeleted(true) + givenClusterExists(true) + givenEditorIsModified(false) + givenEditorIsOutdated(false) // when editor.update() // then - verifyHideAllNotifications() + verify(deletedNotification).show(any()) } @Test - fun `#update should hide all notifications when resource is outdated`() { + fun `#update should show push notification if resource is modified`() { // given - givenEditorResourceIsModified(false) - givenEditorResourceIsOutdated(true) + givenClusterIsDeleted(false) + givenClusterExists(true) + givenEditorIsModified(true) + givenEditorIsOutdated(false) // when editor.update() // then - verifyHideAllNotifications() + verify(pushNotification).show(any(), any()) } @Test - fun `#update should hide all notifications when editor resource is NOT modified NOR outdated`() { + fun `#update should show error notification if resource has neither name nor generateName`() { // given - givenEditorResourceIsModified(false) - givenEditorResourceIsOutdated(false) + givenClusterIsDeleted(false) + givenClusterExists(true) + givenEditorIsModified(true) + givenEditorIsOutdated(false) + doReturn(listOf(PodBuilder(GARGAMEL_INITIAL) + .editMetadata() + .withName(null) + .withGenerateName(null) + .endMetadata() + .build())) + .whenever(createResources).invoke(any(), any(), any()) // when editor.update() // then - verifyHideAllNotifications() + verify(errorNotification).show(any(), anyOrNull() as String?) } @Test - fun `#update should show error notification and hide all notifications if creating editor resource throws ResourceException`() { + fun `#update should NOT show error notification if resource has name`() { // given - doThrow(ResourceException("resource error", KubernetesClientException("client error"))) - .whenever(createResource).invoke(any()) + givenClusterIsDeleted(false) + givenClusterExists(true) + givenEditorIsModified(true) + givenEditorIsOutdated(false) + doReturn(listOf(PodBuilder(GARGAMEL_INITIAL) + .editMetadata() + .withName("gargantuan") + .withGenerateName(null) + .endMetadata() + .build())) + .whenever(createResources).invoke(any(), any(), any()) // when editor.update() // then - verify(errorNotification).show(any(), argWhere { it.contains("client error") }) - verifyHideAllNotifications() + verify(errorNotification, never()).show(any(), anyOrNull() as String?) } @Test - fun `#update should show deleted notification if resource on cluster is deleted`() { + fun `#update should NOT show error notification if resource has generateName`() { + // given + givenClusterIsDeleted(false) + givenClusterExists(true) + givenEditorIsModified(true) + givenEditorIsOutdated(false) + doReturn(listOf(PodBuilder(GARGAMEL_INITIAL) + .editMetadata() + .withName(null) + .withGenerateName("gargantuan") + .endMetadata() + .build())) + .whenever(createResources).invoke(any(), any(), any()) + // when + editor.update() + // then + verify(errorNotification, never()).show(any(), anyOrNull() as String?) + } + + @Test + fun `#update should show pull notification if resource is outdated`() { // given - givenEditorResourceIsModified(false) - givenEditorResourceIsOutdated(false) + givenClusterIsDeleted(false) + givenClusterExists(true) + givenEditorIsModified(false) + givenEditorIsOutdated(true) // when - editor.update(true) + editor.update() // then - verify(deletedNotification).show(any()) + verify(pullNotification).show(any()) } @Test - fun `#update should show push notification if resource is modified`() { + fun `#update should show push notification when there are several resources and one is modified`() { // given - givenEditorResourceIsModified(true) - givenEditorResourceIsOutdated(false) + givenClusterIsDeleted(false) + givenClusterExists(true) + givenEditorIsModified(true) + givenEditorIsOutdated(false) + doReturn(listOf(GARGAMEL_INITIAL, GARGAMEL_MODIFIED)) + .whenever(createResources).invoke(any(), any(), any()) // when editor.update() // then - verify(pushNotification).show(any(), any()) + verify(pushNotification).show(any(), argThat(ArgumentMatcher { states -> + states.size == 1 + && states.first().resource == GARGAMEL_MODIFIED + })) } @Test - fun `#update should show pull notification if resource is outdated`() { + fun `#update should show push notification when there are several deleted resources`() { // given - givenEditorResourceIsModified(false) - givenEditorResourceIsOutdated(true) + givenClusterIsDeleted(true) + givenClusterExists(true) + givenEditorIsModified(false) + givenEditorIsOutdated(false) + doReturn(listOf(GARGAMEL_INITIAL, GARGAMEL_MODIFIED)) + .whenever(createResources).invoke(any(), any(), any()) // when editor.update() // then - verify(pullNotification).show(any(), any()) + verify(pushNotification).show(any(), argThat(ArgumentMatcher { states -> + states.size == 2 + && states.map { it.resource } + .containsAll(listOf(GARGAMEL_INITIAL, GARGAMEL_MODIFIED)) + })) } @Test - fun `#update should NOT save resource version if resource in editor has no resource version`() { + fun `#update should NOT show push notification when there are several outdated resources`() { // given - val resource = PodBuilder(GARGAMEL) - .editMetadata() - .withResourceVersion(null) - .endMetadata() - .build() - doReturn(resource) - .whenever(createResource).invoke(any()) + givenClusterIsDeleted(false) + givenClusterExists(true) + givenEditorIsModified(false) + givenEditorIsOutdated(true) + doReturn(listOf(GARGAMEL_INITIAL, GARGAMEL_PUSHED_PULLED)) + .whenever(createResources).invoke(any(), any(), any()) // when editor.update() // then - verify(resourceVersion, never()).set(any()) + verify(pushNotification, never()).show(any(), any()) } @Test - fun `#update after a #pull should do nothing bcs it was triggered by #replaceDocument (replace triggers editor transaction listener and thus #update)`() { + fun `#update should hide all notifications if resource is identical`() { // given - doReturn(GARGAMELv2) - .whenever(clusterResource).pull(any()) - editor.pull() - clearAllNotificationInvocations() + givenClusterIsDeleted(false) + givenClusterExists(true) + givenEditorIsModified(false) + givenEditorIsOutdated(false) // when editor.update() // then - verifyShowNoNotifications() + verifyHideAllNotifications() } @Test - fun `#update after change of resource should show push notification`() { + fun `#update should do nothing when called after #pull (bcs it was triggered by #replaceDocument)`() { // given - givenEditorResourceIsModified(false) - doReturn(AZRAEL) - .whenever(createResource).invoke(any()) + givenClusterIsDeleted(false) + givenClusterExists(true) + givenEditorIsModified(false) + givenEditorIsOutdated(true) + editor.initEditorResources(document) + editor.pull() + clearInvocations(pulledNotification) // ignore pulled notification triggered by #pull // when editor.update() // then - verify(pushNotification).show(any(), any()) + verifyShowNoNotifications() } @Test fun `#pull should replace document`() { // given - doReturn(GARGAMELv2) - .whenever(clusterResource).pull(any()) // when editor.pull() // then - verify(document).replaceString(0, document.textLength, Serialization.asYaml(GARGAMELv2)) + verify(document).replaceString(0, document.textLength, Serialization.asYaml(GARGAMEL_PUSHED_PULLED)) } @Test - fun `#pull should replace document with json if psiFile is json`() { + fun `#pull should NOT replace document if pulled document is same`() { // given - doReturn(GARGAMELv2) - .whenever(clusterResource).pull(any()) - doReturn(JsonFileType.INSTANCE) - .whenever(psiFile).fileType + val existing = editor.editorResources + reset(serialize) + doReturn(EditorResourceSerialization.serialize(existing, YAMLFileType.YML)) + .whenever(serialize).invoke(any(), any()) // when editor.pull() // then - verify(document).replaceString(0, document.textLength, Serialization.asJson(GARGAMELv2)) + verify(document, never()).replaceString(any(), any(), any()) } @Test - fun `#pull should replace document with yaml if psiFile is yaml`() { + fun `#pull should NOT replace document if document differs by a newline`() { // given - doReturn(GARGAMELv2) - .whenever(clusterResource).pull(any()) - doReturn(YAMLFileType.YML) - .whenever(psiFile).fileType + val existing = editor.editorResources + reset(serialize) + val yaml = EditorResourceSerialization.serialize(existing, YAMLFileType.YML) + doReturn(yaml) + .whenever(serialize).invoke(any(), any()) + doReturn(yaml + "\n") + .whenever(document).text // when editor.pull() // then - verify(document).replaceString(0, document.textLength, Serialization.asYaml(GARGAMELv2)) + verify(document, never()).replaceString(any(), any(), any()) } @Test - fun `#pull should NOT replace document if resource is equal`() { + fun `#pull should deserialize to json if psiFile is json`() { // given - doReturn(GARGAMELv2) - .whenever(clusterResource).push(any()) - doReturn(Serialization.asYaml(GARGAMELv2)) - .whenever(document).getText() + doReturn(JsonFileType.INSTANCE) + .whenever(psiFile).fileType // when editor.pull() // then - verify(document, never()).replaceString(any(), any(), any()) + verify(serialize).invoke(any(), eq(JsonFileType.INSTANCE)) } @Test - fun `#pull should NOT replace document if resource differs by a newline`() { + fun `#pull should deserialize to json if psiFile is yaml`() { // given - doReturn(GARGAMELv2) - .whenever(clusterResource).push(any()) - doReturn(Serialization.asYaml(GARGAMELv2) + "\n") - .whenever(document).getText() + doReturn(YAMLFileType.YML) + .whenever(psiFile).fileType // when editor.pull() // then - verify(document, never()).replaceString(any(), any(), any()) + verify(serialize).invoke(any(), eq(YAMLFileType.YML)) } @Test fun `#pull should commit document`() { // given - doReturn(GARGAMELv2) - .whenever(clusterResource).pull(any()) + // then // when editor.pull() // then @@ -468,12 +529,10 @@ spec: @Test fun `#pull should show pulled notification`() { // given - doReturn(GARGAMELv2) - .whenever(clusterResource).pull(any()) // when editor.pull() // then - verify(pulledNotification).show(GARGAMELv2) + verify(pulledNotification).show(GARGAMEL_PUSHED_PULLED) } @Test @@ -499,33 +558,80 @@ spec: @Test fun `#pull should save resource version`() { // given - doReturn(GARGAMELv2) + doReturn(GARGAMEL_PUSHED_PULLED) .whenever(clusterResource).pull() // when editor.pull() // then - verify(resourceVersion).set(GARGAMELv2.metadata.resourceVersion) + verify(attributes).setResourceVersion( + GARGAMEL_PUSHED_PULLED, + GARGAMEL_PUSHED_PULLED.metadata.resourceVersion) + } + + @Test + fun `#push should push resource if editor resource is modified`() { + // given + givenClusterExists(false) + givenEditorIsModified(true) + // when + editor.push() + // then + verify(clusterResource).push(GARGAMEL_INITIAL) + } + + @Test + fun `#push should show pushed notification`() { + // given + givenClusterExists(false) + givenEditorIsModified(true) + // when + editor.push() + // then + verify(pushedNotification).show(any()) } @Test - fun `#push should push resource to cluster`() { + fun `#push should push resource if editor resource is NOT modified but does not exist on cluster`() { // given + givenClusterExists(false) + givenEditorIsModified(false) // when editor.push() // then - verify(clusterResource).push(any()) + verify(clusterResource).push(GARGAMEL_INITIAL) + } + + @Test + fun `#push should NOT push resource if editor resource is NOT modified and exists on cluster`() { + // given + givenClusterExists(true) + givenEditorIsModified(false) + // when + editor.push() + // then + verify(clusterResource, never()).push(any()) } @Test fun `#push should save version of resource that cluster responded with`() { // given - val versionInResponse = GARGAMELv2.metadata.resourceVersion - doReturn(GARGAMELv2) - .whenever(clusterResource).push(any()) + givenClusterExists(false) + givenEditorIsModified(true) // when editor.push() // then - verify(resourceVersion).set(versionInResponse) + verify(attributes).setResourceVersion(any(), eq(GARGAMEL_PUSHED_PULLED.metadata.resourceVersion)) + } + + @Test + fun `#push should save editor resource that was pushed`() { + // given + givenClusterExists(false) + givenEditorIsModified(true) + // when + editor.push() + // then save local resource that was pushed + verify(attributes).setLastPushedPulled(GARGAMEL_INITIAL) } @Test @@ -597,8 +703,6 @@ spec: @Test fun `#stopWatch should stop watching the cluster`() { // given - // force create cluster resource - editor.clusterResource // when editor.stopWatch() // then @@ -606,32 +710,32 @@ spec: } @Test - fun `#diff should open diff with json if editor file is json`() { + fun `#diff should serialize cluster resource to json if editor is json`() { // given doReturn(JsonFileType.INSTANCE) - .whenever(psiFile).getFileType() + .whenever(psiFile).fileType // when editor.diff() // then - verify(diff).open(any(), argThat { startsWith("{") }, any()) + verify(serialize).invoke(any(), eq(JsonFileType.INSTANCE)) } @Test - fun `#diff should open diff with yml if editor file is yml`() { + fun `#diff should serialize cluster resource to yml if editor is yml`() { // given doReturn(YAMLFileType.YML) - .whenever(psiFile).getFileType() + .whenever(psiFile).fileType // when editor.diff() // then - verify(diff).open(any(), argThat { startsWith("---") }, any()) + verify(serialize).invoke(any(), eq(YAMLFileType.YML)) } @Test fun `#diff should NOT open diff if editor file type is null`() { // given doReturn(null) - .whenever(psiFile).getFileType() + .whenever(psiFile).fileType // when editor.diff() // then @@ -639,25 +743,25 @@ spec: } @Test - fun `#onDiffClosed should save resource version if document has changed`() { + fun `#onDiffClosed should update resource attributes if document has changed`() { // given - doReturn("{ apiVersion: v2 }") + doReturn("{ apiVersion: v1 }") .whenever(document).text // when - editor.onDiffClosed(GARGAMEL, "{ apiVersion: v1 }") + editor.onDiffClosed("{ apiVersion: v2 }") // then - verify(resourceVersion, atLeastOnce()).set(any()) + verify(attributes).update(any()) } @Test - fun `#onDiffClosed should NOT save resource version if document has NOT changed`() { + fun `#onDiffClosed should NOT update resource attributes if document has NOT changed`() { // given doReturn("{ apiVersion: v1 }") .whenever(document).text // when - editor.onDiffClosed(GARGAMEL, "{ apiVersion: v1 }") + editor.onDiffClosed("{ apiVersion: v1 }") // then - verify(resourceVersion, never()).set(any()) + verify(attributes, never()).update(any()) } @Test @@ -666,17 +770,7 @@ spec: // when editor.removeClutter() // then - verify(document).replaceString(0, document.textLength, Serialization.asYaml(GARGAMEL)) - } - - @Test - fun `#removeClutter should NOT replace document if there's no resource`() { - // given - editor.editorResource.set(null) - // when - editor.removeClutter() - // then - verify(document, never()).replaceString(any(), any(), any()) + verify(document).replaceString(0, document.textLength, Serialization.asYaml(GARGAMEL_INITIAL)) } @Test @@ -688,166 +782,148 @@ spec: verifyHideAllNotifications() } - private fun verifyHideAllNotifications() { - verify(errorNotification).hide() - verify(pullNotification).hide() - verify(deletedNotification).hide() - verify(pushNotification).hide() - verify(pulledNotification).hide() - } - - private fun verifyShowNoNotifications() { - verify(errorNotification, never()).show(any(), any()) - verify(pullNotification, never()).show(any(), any()) - verify(deletedNotification, never()).show(any()) - verify(pushNotification, never()).show(any(), any()) - verify(pulledNotification, never()).show(any()) - } - - private fun clearAllNotificationInvocations() { - clearInvocations(errorNotification) - clearInvocations(pullNotification) - clearInvocations(deletedNotification) - clearInvocations(pushNotification) - clearInvocations(pulledNotification) - } - @Test - fun `#close should close cluster resource`() { + fun `#isEditing should return true if is given resource is same as editor resource`() { // given - // force creation of cluster resource - editor.clusterResource + val resource = editor.editorResources.first() // when - editor.close() + val isEditing = editor.isEditing(resource) // then - verify(clusterResource).close() + assertThat(isEditing).isTrue } - @Test - fun `#close should remove editor from virtual file user data`() { + @Test + fun `#isEditing should return false if is given resource has different name`() { // given + val resource = PodBuilder(editor.editorResources.first() as Pod) + .editOrNewMetadata() + .withName("azrael") + .endMetadata() + .build() // when - editor.close() + val isEditing = editor.isEditing(resource) // then - verify(virtualFile).putUserData(ResourceEditor.KEY_RESOURCE_EDITOR, null) + assertThat(isEditing).isFalse } @Test - fun `#close should remove editor user data`() { + fun `#isEditing should return false if is given resource has different namespace`() { // given + val resource = PodBuilder(editor.editorResources.first() as Pod) + .editOrNewMetadata() + .withNamespace("smurf village") + .endMetadata() + .build() // when - editor.close() + val isEditing = editor.isEditing(resource) // then - verify(fileEditor).putUserData(ResourceEditor.KEY_RESOURCE_EDITOR, null) + assertThat(isEditing).isFalse } @Test - fun `#close should remove listener from resourceModel`() { + fun `#isEditing should return false if is given resource has different apiVersion`() { // given + val resource = PodBuilder(editor.editorResources.first() as Pod) + .withApiVersion("purple smurf") + .build() // when - editor.close() + val isEditing = editor.isEditing(resource) // then - verify(resourceModel).removeListener(editor) + assertThat(isEditing).isFalse } @Test - fun `#close should save resource version`() { + fun `#isEditing should return false if is given resource is NOT same as editor resource`() { // given // when - editor.close() + val isEditing = editor.isEditing(mock()) // then - verify(resourceVersion).save() + assertThat(isEditing).isFalse } @Test - fun `#modified should close cluster resource`() { + fun `#close should dispose attributes`() { // given // when - editor.modified(resourceModel) + editor.close() // then - verify(clusterResource).close() + verify(attributes).dispose() } @Test - fun `#modified should NOT close cluster resource if modified object is NOT IResourceModel`() { + fun `#close should remove editor from virtual file user data`() { // given // when - editor.modified(mock()) + editor.close() // then - verify(clusterResource, never()).close() + verify(virtualFile).putUserData(ResourceEditor.KEY_RESOURCE_EDITOR, null) } @Test - fun `#modified should clear saved resourceVersion`() { + fun `#close should remove editor user data`() { // given // when - editor.modified(mock()) + editor.close() // then - verify(resourceVersion).set(null) + verify(fileEditor).putUserData(ResourceEditor.KEY_RESOURCE_EDITOR, null) } @Test - fun `#currentNamespace should recreate cluster resource`() { + fun `#close should remove listener from resourceModel`() { // given - whenever(clusterResource.isClosed()) - .doReturn(false) // when - editor.currentNamespaceChanged(mock(), mock()) + editor.close() // then - verify(clusterResourceFactory).invoke(any(), any()) + verify(resourceModel).removeListener(any()) } - @Test - fun `#currentNamespace should clear saved resourceVersion`() { - // given - // when - editor.currentNamespaceChanged(mock(), mock()) - // then - verify(resourceVersion).set(null) + private fun verifyHideAllNotifications() { + verify(errorNotification).hide() + verify(pullNotification).hide() + verify(pulledNotification).hide() + verify(deletedNotification).hide() + verify(pushNotification).hide() + verify(pushedNotification).hide() } - @Test - fun `#init should start listening to resource model`() { - // given - // when - // then - verify(resourceModel).addListener(editor) + private fun verifyShowNoNotifications() { + verify(errorNotification, never()).show(any(), any()) + verify(pullNotification, never()).show(any()) + verify(deletedNotification, never()).show(any()) + verify(pushNotification, never()).show(any(), any()) + verify(pulledNotification, never()).show(any()) } - @Test - fun `#dispose should stop listening to resource model`() { - // given - // when - editor.dispose() - // then - verify(resourceModel).removeListener(editor) + private fun givenClusterIsDeleted(deleted: Boolean) { + doReturn(deleted) + .whenever(clusterResource).isDeleted() } - private fun givenEditorResourceIsOutdated(outdated: Boolean) { - doReturn(outdated) - .whenever(clusterResource).isOutdated(any() as String?) + private fun givenClusterExists(exists: Boolean) { + doReturn(exists) + .whenever(clusterResource).exists() } - private fun givenEditorResourceIsModified(modified: Boolean) { + private fun givenEditorIsModified(modified: Boolean) { + /** + * toggles [ResourceEditor.isModified] + */ val editorResource = if (modified) { - GARGAMEL_WITH_LABEL + GARGAMEL_MODIFIED } else { - GARGAMEL + GARGAMEL_INITIAL } - /** - * Workaround: force [ResourceEditor.clusterResource] to be created - * & reset [ResourceEditor.lastPushedPulled] so that no reset happens anymore. - * - * [ResourceEditor.lastPushedPulled.get] is causing initialization of [ResourceEditor.lastPushedPulled] - * which is causing [ResourceEditor.clusterResource] to be created which then resets [ResourceEditor.lastPushedPulled] - * */ - editor.lastPushedPulled.get() // WORKAROUND - editor.lastPushedPulled.set(GARGAMEL) - editor.editorResource.set(editorResource) - doReturn(editorResource) - .whenever(createResource).invoke(any()) - doReturn(editorResource.metadata.resourceVersion) - .whenever(resourceVersion).get() + doReturn(listOf(editorResource)) + .whenever(createResources).invoke(any(), any(), any()) + + val pulledResource = GARGAMEL_INITIAL + doReturn(pulledResource) + .whenever(attributes).getLastPulledPushed(any()) + } + + private fun givenEditorIsOutdated(outdated: Boolean) { + doReturn(outdated) + .whenever(clusterResource).isOutdatedVersion(anyOrNull()) } } @@ -856,10 +932,11 @@ private class TestableResourceEditor( editor: FileEditor, resourceModel: IResourceModel, project: Project, - resourceFactory: (editor: FileEditor) -> HasMetadata?, - clusterResourceFactory: (resource: HasMetadata?, context: IActiveContext?) -> ClusterResource?, + createResources: (string: String?, fileType: FileType?, currentNamespace: String?) -> List, + serialize: (resources: Collection, fileType: FileType?) -> String?, resourceFileForVirtual: (file: VirtualFile?) -> ResourceFile?, pushNotification: PushNotification, + pushedNotification: PushedNotification, pullNotification: PullNotification, pulledNotification: PulledNotification, deletedNotification: DeletedNotification, @@ -868,16 +945,17 @@ private class TestableResourceEditor( psiDocumentManagerProvider: (Project) -> PsiDocumentManager, getKubernetesResourceInfo: (VirtualFile?, Project) -> KubernetesResourceInfo, documentReplaced: AtomicBoolean, - resourceVersion: PersistentEditorValue, - diff: ResourceDiff + diff: ResourceDiff, + attributes: EditorResourceAttributes ) : ResourceEditor( editor, resourceModel, project, - resourceFactory, - clusterResourceFactory, + createResources, + serialize, resourceFileForVirtual, pushNotification, + pushedNotification, pullNotification, pulledNotification, deletedNotification, @@ -886,19 +964,17 @@ private class TestableResourceEditor( psiDocumentManagerProvider, getKubernetesResourceInfo, documentReplaced, - resourceVersion, - diff + diff, + attributes ) { - public override var lastPushedPulled: ResettableLazyProperty = super.lastPushedPulled - public override var clusterResource: ClusterResource? = super.clusterResource - public override fun onDiffClosed(resource: HasMetadata, documentBeforeDiff: String?) { - // allow public visibility - return super.onDiffClosed(resource, documentBeforeDiff) + fun initEditorResources(document: Document) { + super.createEditorResources(document) } - public override fun isModified(): Boolean { - return super.isModified() + public override fun onDiffClosed(documentBeforeDiff: String?) { + // allow public visibility + return super.onDiffClosed(documentBeforeDiff) } override fun runAsync(runnable: () -> Unit) {