This is an automated email from the ASF dual-hosted git repository. sureshanaparti pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/cloudstack.git
The following commit(s) were added to refs/heads/main by this push: new 4aed972e78c api,server,extensions: allow updating extension resource map details (#11303) 4aed972e78c is described below commit 4aed972e78c44674a2f65683f2f4434bd39de979 Author: Abhishek Kumar <abhishek.mr...@gmail.com> AuthorDate: Tue Jul 29 10:25:52 2025 +0530 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 <abhishek.mr...@gmail.com> --- .../command/admin/cluster/UpdateClusterCmd.java | 12 ++++ .../extensions/manager/ExtensionsManager.java | 6 ++ .../extensions/manager/ExtensionsManagerImpl.java | 39 +++++++++++ .../manager/ExtensionsManagerImplTest.java | 76 ++++++++++++++++++++++ .../com/cloud/resource/ResourceManagerImpl.java | 17 ++++- .../extension/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].<key>=<value>. 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<String, String> 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<String, Map<String, String>> getExternalAccessDetails(Host host, Map<String, String> vmDetails); String handleExtensionServerCommands(ExtensionServerActionBaseCommand cmd); + + Pair<Boolean, ExtensionResourceMap> extensionResourceMapDetailsNeedUpdate(final long resourceId, + final ExtensionResourceMap.ResourceType resourceType, final Map<String, String> details); + + void updateExtensionResourceMapDetails(final long extensionResourceMapId, final Map<String, String> 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<Boolean, ExtensionResourceMap> extensionResourceMapDetailsNeedUpdate(long resourceId, + ExtensionResourceMap.ResourceType resourceType, Map<String, String> 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<String, String> mapDetails = + extensionResourceMapDetailsDao.listDetailsKeyPairs(extensionResourceMapVO.getId()); + if (MapUtils.isEmpty(mapDetails) || mapDetails.size() != externalDetails.size()) { + return new Pair<>(true, extensionResourceMapVO); + } + for (Map.Entry<String, String> 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<String, String> details) { + if (MapUtils.isEmpty(details)) { + return; + } + List<ExtensionResourceMapDetailsVO> detailsList = new ArrayList<>(); + for (Map.Entry<String, String> 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<String, String> externalDetails = Map.of("key", "value"); + Pair<Boolean, ExtensionResourceMap> 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<String, String> externalDetails = Map.of("key", "value"); + Pair<Boolean, ExtensionResourceMap> 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<String, String> externalDetails = Map.of("key", "newValue"); + Pair<Boolean, ExtensionResourceMap> 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<String, String> externalDetails = Map.of("key", "value", "extra", "something"); + Pair<Boolean, ExtensionResourceMap> 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<String, String> 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<String, String> 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<String, String> externalDetails = cmd.getExternalDetails(); // Verify cluster information and update the cluster if needed boolean doUpdate = false; + Pair<Boolean, ExtensionResourceMap> 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 @@ </a-select-option> </a-select> </a-form-item> + <a-form-item name="externaldetails" ref="externaldetails" v-if="resource.hypervisortype === 'External' && resource.extensionid"> + <template #label> + <tooltip-label :title="$t('label.configuration.details')" :tooltip="apiParams.externaldetails.description"/> + </template> + <div style="margin-bottom: 10px">{{ $t('message.add.extension.resource.details') }}</div> + <details-input + v-model:value="form.externaldetails" /> + </a-form-item> <div :span="24" class="action-button"> <a-button :loading="loading" @click="onCloseAction">{{ $t('label.cancel') }}</a-button> @@ -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 => {