From 4aed972e78c44674a2f65683f2f4434bd39de979 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Tue, 29 Jul 2025 10:25:52 +0530 Subject: [PATCH] api,server,extensions: allow updating extension resource map details (#11303) * api,server,extensions: allow updating extension resource map details This PR makes changes for allowing updating details for an extension resource mapping. Currently, extensions only support Cluster to be registered therefore changes has been added to updateCluster functionality. Signed-off-by: Abhishek Kumar --- .../admin/cluster/UpdateClusterCmd.java | 12 +++ .../extensions/manager/ExtensionsManager.java | 6 ++ .../manager/ExtensionsManagerImpl.java | 39 ++++++++++ .../manager/ExtensionsManagerImplTest.java | 76 +++++++++++++++++++ .../cloud/resource/ResourceManagerImpl.java | 17 ++++- .../ExternalConfigurationDetails.vue | 3 +- ui/src/views/infra/ClusterUpdate.vue | 41 +++++++++- 7 files changed, 190 insertions(+), 4 deletions(-) diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/cluster/UpdateClusterCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/cluster/UpdateClusterCmd.java index 816285e3430..c160cfd2e03 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/cluster/UpdateClusterCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/cluster/UpdateClusterCmd.java @@ -16,6 +16,8 @@ // under the License. package org.apache.cloudstack.api.command.admin.cluster; +import java.util.Map; + import com.cloud.cpu.CPU; import org.apache.cloudstack.api.ApiCommandResourceType; @@ -60,6 +62,12 @@ public class UpdateClusterCmd extends BaseCmd { since = "4.20") private String arch; + @Parameter(name = ApiConstants.EXTERNAL_DETAILS, + type = CommandType.MAP, + description = "Details in key/value pairs to be added to the extension-resource mapping. Use the format externaldetails[i].=. Example: externaldetails[0].endpoint.url=https://example.com", + since = "4.21.0") + protected Map externalDetails; + public String getClusterName() { return clusterName; } @@ -122,6 +130,10 @@ public class UpdateClusterCmd extends BaseCmd { return CPU.CPUArch.fromType(arch); } + public Map getExternalDetails() { + return convertDetailsToMap(externalDetails); + } + @Override public void execute() { Cluster cluster = _resourceService.getCluster(getId()); diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java index 8b9ad96b3c4..82174872e87 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java @@ -46,6 +46,7 @@ import org.apache.cloudstack.framework.extensions.command.ExtensionServerActionB import com.cloud.host.Host; import com.cloud.org.Cluster; +import com.cloud.utils.Pair; import com.cloud.utils.component.Manager; public interface ExtensionsManager extends Manager { @@ -87,4 +88,9 @@ public interface ExtensionsManager extends Manager { Map> getExternalAccessDetails(Host host, Map vmDetails); String handleExtensionServerCommands(ExtensionServerActionBaseCommand cmd); + + Pair extensionResourceMapDetailsNeedUpdate(final long resourceId, + final ExtensionResourceMap.ResourceType resourceType, final Map details); + + void updateExtensionResourceMapDetails(final long extensionResourceMapId, final Map details); } diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java index 3087f184dde..5abf0f424a7 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java @@ -1478,6 +1478,45 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana return GsonHelper.getGson().toJson(answers); } + @Override + public Pair extensionResourceMapDetailsNeedUpdate(long resourceId, + ExtensionResourceMap.ResourceType resourceType, Map externalDetails) { + if (MapUtils.isEmpty(externalDetails)) { + return new Pair<>(false, null); + } + ExtensionResourceMapVO extensionResourceMapVO = + extensionResourceMapDao.findByResourceIdAndType(resourceId, resourceType); + if (extensionResourceMapVO == null) { + return new Pair<>(true, null); + } + Map mapDetails = + extensionResourceMapDetailsDao.listDetailsKeyPairs(extensionResourceMapVO.getId()); + if (MapUtils.isEmpty(mapDetails) || mapDetails.size() != externalDetails.size()) { + return new Pair<>(true, extensionResourceMapVO); + } + for (Map.Entry entry : externalDetails.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + if (!value.equals(mapDetails.get(key))) { + return new Pair<>(true, extensionResourceMapVO); + } + } + return new Pair<>(false, extensionResourceMapVO); + } + + @Override + public void updateExtensionResourceMapDetails(long extensionResourceMapId, Map details) { + if (MapUtils.isEmpty(details)) { + return; + } + List detailsList = new ArrayList<>(); + for (Map.Entry entry : details.entrySet()) { + detailsList.add(new ExtensionResourceMapDetailsVO(extensionResourceMapId, entry.getKey(), + entry.getValue())); + } + extensionResourceMapDetailsDao.saveDetails(detailsList); + } + @Override public Long getExtensionIdForCluster(long clusterId) { ExtensionResourceMapVO map = extensionResourceMapDao.findByResourceIdAndType(clusterId, diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java index 00bf915831b..fcceb16523e 100644 --- a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java @@ -1742,6 +1742,82 @@ public class ExtensionsManagerImplTest { assertTrue(json.contains("\"result\":false")); } + @Test + public void extensionResourceMapDetailsNeedUpdateReturnsTrueWhenNoResourceMapExists() { + when(extensionResourceMapDao.findByResourceIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(null); + Map externalDetails = Map.of("key", "value"); + Pair result = extensionsManager.extensionResourceMapDetailsNeedUpdate(1L, + ExtensionResourceMap.ResourceType.Cluster, externalDetails); + assertTrue(result.first()); + assertNull(result.second()); + } + + @Test + public void extensionResourceMapDetailsNeedUpdateReturnsFalseWhenDetailsMatch() { + ExtensionResourceMapVO resourceMap = mock(ExtensionResourceMapVO.class); + when(extensionResourceMapDao.findByResourceIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(resourceMap); + when(extensionResourceMapDetailsDao.listDetailsKeyPairs(resourceMap.getId())).thenReturn(Map.of("key", "value")); + Map externalDetails = Map.of("key", "value"); + Pair result = extensionsManager.extensionResourceMapDetailsNeedUpdate(1L, + ExtensionResourceMap.ResourceType.Cluster, externalDetails); + assertFalse(result.first()); + assertEquals(resourceMap, result.second()); + } + + @Test + public void extensionResourceMapDetailsNeedUpdateReturnsTrueWhenDetailsDiffer() { + ExtensionResourceMapVO resourceMap = mock(ExtensionResourceMapVO.class); + when(extensionResourceMapDao.findByResourceIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(resourceMap); + when(extensionResourceMapDetailsDao.listDetailsKeyPairs(resourceMap.getId())).thenReturn(Map.of("key", "oldValue")); + Map externalDetails = Map.of("key", "newValue"); + Pair result = extensionsManager.extensionResourceMapDetailsNeedUpdate(1L, + ExtensionResourceMap.ResourceType.Cluster, externalDetails); + assertTrue(result.first()); + assertEquals(resourceMap, result.second()); + } + + @Test + public void extensionResourceMapDetailsNeedUpdateReturnsTrueWhenExternalDetailsHaveExtraKeys() { + ExtensionResourceMapVO resourceMap = mock(ExtensionResourceMapVO.class); + when(extensionResourceMapDao.findByResourceIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(resourceMap); + when(extensionResourceMapDetailsDao.listDetailsKeyPairs(resourceMap.getId())).thenReturn(Map.of("key", "value")); + Map externalDetails = Map.of("key", "value", "extra", "something"); + Pair result = extensionsManager.extensionResourceMapDetailsNeedUpdate(1L, + ExtensionResourceMap.ResourceType.Cluster, externalDetails); + assertTrue(result.first()); + assertEquals(resourceMap, result.second()); + } + + @Test + public void updateExtensionResourceMapDetails_SavesDetails_WhenDetailsProvided() { + long resourceMapId = 100L; + Map details = Map.of("foo", "bar", "baz", "qux"); + extensionsManager.updateExtensionResourceMapDetails(resourceMapId, details); + verify(extensionResourceMapDetailsDao).saveDetails(any()); + } + + @Test + public void updateExtensionResourceMapDetails_RemovesDetails_WhenDetailsIsNull() { + long resourceMapId = 101L; + extensionsManager.updateExtensionResourceMapDetails(resourceMapId, null); + verify(extensionResourceMapDetailsDao, never()).saveDetails(any()); + } + + @Test + public void updateExtensionResourceMapDetails_RemovesDetails_WhenDetailsIsEmpty() { + long resourceMapId = 102L; + extensionsManager.updateExtensionResourceMapDetails(resourceMapId, Collections.emptyMap()); + verify(extensionResourceMapDetailsDao, never()).saveDetails(any()); + } + + @Test(expected = CloudRuntimeException.class) + public void updateExtensionResourceMapDetails_ThrowsException_WhenSaveFails() { + long resourceMapId = 103L; + Map details = Map.of("foo", "bar"); + doThrow(CloudRuntimeException.class).when(extensionResourceMapDetailsDao).saveDetails(any()); + extensionsManager.updateExtensionResourceMapDetails(resourceMapId, details); + } + @Test public void getExtensionIdForCluster_WhenMappingExists_ReturnsExtensionId() { long clusterId = 1L; diff --git a/server/src/main/java/com/cloud/resource/ResourceManagerImpl.java b/server/src/main/java/com/cloud/resource/ResourceManagerImpl.java index 999b46e9f9f..936dfd9cf95 100755 --- a/server/src/main/java/com/cloud/resource/ResourceManagerImpl.java +++ b/server/src/main/java/com/cloud/resource/ResourceManagerImpl.java @@ -189,6 +189,7 @@ import com.cloud.storage.dao.VMTemplateDao; import com.cloud.storage.dao.VolumeDao; import com.cloud.user.Account; import com.cloud.user.AccountManager; +import com.cloud.utils.Pair; import com.cloud.utils.StringUtils; import com.cloud.utils.Ternary; import com.cloud.utils.UriUtils; @@ -223,8 +224,8 @@ import com.cloud.vm.VirtualMachineManager; import com.cloud.vm.VirtualMachineProfile; import com.cloud.vm.VirtualMachineProfileImpl; import com.cloud.vm.VmDetailConstants; -import com.cloud.vm.dao.VMInstanceDetailsDao; import com.cloud.vm.dao.VMInstanceDao; +import com.cloud.vm.dao.VMInstanceDetailsDao; import com.google.gson.Gson; @Component @@ -1224,9 +1225,18 @@ public class ResourceManagerImpl extends ManagerBase implements ResourceManager, String managedstate = cmd.getManagedstate(); String name = cmd.getClusterName(); CPU.CPUArch arch = cmd.getArch(); + final Map externalDetails = cmd.getExternalDetails(); // Verify cluster information and update the cluster if needed boolean doUpdate = false; + Pair needDetailsUpdateMapPair = + extensionsManager.extensionResourceMapDetailsNeedUpdate(cluster.getId(), + ExtensionResourceMap.ResourceType.Cluster, externalDetails); + if (Boolean.TRUE.equals(needDetailsUpdateMapPair.first()) && needDetailsUpdateMapPair.second() == null) { + throw new InvalidParameterValueException( + String.format("Cluster: %s is not registered with any extension, details cannot be updated", + cluster.getName())); + } if (StringUtils.isNotBlank(name)) { if(cluster.getHypervisorType() == HypervisorType.VMware) { @@ -1311,6 +1321,11 @@ public class ResourceManagerImpl extends ManagerBase implements ResourceManager, _clusterDao.update(cluster.getId(), cluster); } + if (Boolean.TRUE.equals(needDetailsUpdateMapPair.first())) { + ExtensionResourceMap extensionResourceMap = needDetailsUpdateMapPair.second(); + extensionsManager.updateExtensionResourceMapDetails(extensionResourceMap.getId(), externalDetails); + } + if (newManagedState != null && !newManagedState.equals(oldManagedState)) { if (newManagedState.equals(Managed.ManagedState.Unmanaged)) { boolean success = false; diff --git a/ui/src/views/extension/ExternalConfigurationDetails.vue b/ui/src/views/extension/ExternalConfigurationDetails.vue index 4322651ea3e..e7b3f298fe2 100644 --- a/ui/src/views/extension/ExternalConfigurationDetails.vue +++ b/ui/src/views/extension/ExternalConfigurationDetails.vue @@ -94,7 +94,8 @@ export default { }, methods: { fetchData () { - if (!['cluster'].includes(this.$route.meta.name)) { + if (!['cluster'].includes(this.$route.meta.name) || !this.resource.extensionid) { + this.extension = {} return } this.loading = true diff --git a/ui/src/views/infra/ClusterUpdate.vue b/ui/src/views/infra/ClusterUpdate.vue index a0284a6f6c8..1af7f420e66 100644 --- a/ui/src/views/infra/ClusterUpdate.vue +++ b/ui/src/views/infra/ClusterUpdate.vue @@ -71,6 +71,14 @@ + + +
{{ $t('message.add.extension.resource.details') }}
+ +
{{ $t('label.cancel') }} @@ -84,11 +92,13 @@ import { ref, reactive, toRaw } from 'vue' import { getAPI, postAPI } from '@/api' import TooltipLabel from '@/components/widgets/TooltipLabel' +import DetailsInput from '@/components/widgets/DetailsInput' export default { name: 'ClusterUpdate', components: { - TooltipLabel + TooltipLabel, + DetailsInput }, props: { action: { @@ -145,6 +155,7 @@ export default { fetchData () { this.fetchArchitectureTypes() this.fetchStorageAccessGroupsData() + this.fetchExtensionResourceMapDetails() }, fetchArchitectureTypes () { this.architectureTypes.opts = [] @@ -159,13 +170,39 @@ export default { }) this.architectureTypes.opts = typesList }, + fetchExtensionResourceMapDetails () { + this.form.externaldetails = null + if (!this.resource.id || !this.resource.extensionid) { + return + } + this.loading = true + const params = { + id: this.resource.extensionid, + details: 'resource' + } + getAPI('listExtensions', params).then(json => { + const resources = json?.listextensionsresponse?.extension?.[0]?.resources || [] + const resourceMap = resources.find(r => r.id === this.resource.id) + if (resourceMap && resourceMap.details && typeof resourceMap.details === 'object') { + this.form.externaldetails = resourceMap.details + } + }).catch(error => { + this.$notifyError(error) + }).finally(() => { + this.loading = false + }) + }, handleSubmit () { this.formRef.value.validate().then(() => { const values = toRaw(this.form) - console.log(values) const params = {} params.id = this.resource.id params.clustername = values.name + if (values.externaldetails) { + Object.entries(values.externaldetails).forEach(([key, value]) => { + params['externaldetails[0].' + key] = value + }) + } this.loading = true postAPI('updateCluster', params).then(json => {