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 => {