From 0bf470b8c1a5edc8f7b8760a4e79a88ae3457217 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Mon, 28 Jul 2025 12:29:59 +0530 Subject: [PATCH 1/2] 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 | 44 ++++++++++- 7 files changed, 194 insertions(+), 3 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 816285e34307..c160cfd2e034 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 CPU.CPUArch getArch() { 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 8b9ad96b3c41..82174872e87d 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 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 3087f184dde7..5abf0f424a7c 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 String handleExtensionServerCommands(ExtensionServerActionBaseCommand com 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 00bf915831bd..fcceb16523e8 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 void handleExtensionServerCommands_UnsupportedCommand_ReturnsUnsupportedA 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 999b46e9f9f0..936dfd9cf950 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.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.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 Cluster updateCluster(UpdateClusterCmd cmd) { 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 Cluster updateCluster(UpdateClusterCmd cmd) { _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 4322651ea3e2..e7b3f298fe2c 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 a0284a6f6c8f..34471a951b3a 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,6 +170,32 @@ 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 => { + this.extension = json.listextensionsresponse.extension[0] + if (!this.extension?.resources) { + return null + } + const resourceMap = this.extension.resources.find(r => r.id === this.resource.id) + if (!resourceMap || !resourceMap.details || typeof resourceMap.details !== 'object') { + this.form.externaldetails = null + } + 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) @@ -166,6 +203,11 @@ export default { 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 => { From ef45b351c55ac1f24f316f56c8c7f9008ef69e83 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Mon, 28 Jul 2025 14:45:21 +0530 Subject: [PATCH 2/2] improve ui js wrt copilot comment Signed-off-by: Abhishek Kumar --- ui/src/views/infra/ClusterUpdate.vue | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/ui/src/views/infra/ClusterUpdate.vue b/ui/src/views/infra/ClusterUpdate.vue index 34471a951b3a..1af7f420e666 100644 --- a/ui/src/views/infra/ClusterUpdate.vue +++ b/ui/src/views/infra/ClusterUpdate.vue @@ -181,15 +181,11 @@ export default { details: 'resource' } getAPI('listExtensions', params).then(json => { - this.extension = json.listextensionsresponse.extension[0] - if (!this.extension?.resources) { - return null + 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 } - const resourceMap = this.extension.resources.find(r => r.id === this.resource.id) - if (!resourceMap || !resourceMap.details || typeof resourceMap.details !== 'object') { - this.form.externaldetails = null - } - this.form.externaldetails = resourceMap.details }).catch(error => { this.$notifyError(error) }).finally(() => { @@ -199,7 +195,6 @@ export default { 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