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.mrt22@gmail.com>
This commit is contained in:
Abhishek Kumar 2025-07-29 10:25:52 +05:30 committed by GitHub
parent 9fee6dae34
commit 4aed972e78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 190 additions and 4 deletions

View File

@ -16,6 +16,8 @@
// under the License. // under the License.
package org.apache.cloudstack.api.command.admin.cluster; package org.apache.cloudstack.api.command.admin.cluster;
import java.util.Map;
import com.cloud.cpu.CPU; import com.cloud.cpu.CPU;
import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.api.ApiCommandResourceType;
@ -60,6 +62,12 @@ public class UpdateClusterCmd extends BaseCmd {
since = "4.20") since = "4.20")
private String arch; 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() { public String getClusterName() {
return clusterName; return clusterName;
} }
@ -122,6 +130,10 @@ public class UpdateClusterCmd extends BaseCmd {
return CPU.CPUArch.fromType(arch); return CPU.CPUArch.fromType(arch);
} }
public Map<String, String> getExternalDetails() {
return convertDetailsToMap(externalDetails);
}
@Override @Override
public void execute() { public void execute() {
Cluster cluster = _resourceService.getCluster(getId()); Cluster cluster = _resourceService.getCluster(getId());

View File

@ -46,6 +46,7 @@ import org.apache.cloudstack.framework.extensions.command.ExtensionServerActionB
import com.cloud.host.Host; import com.cloud.host.Host;
import com.cloud.org.Cluster; import com.cloud.org.Cluster;
import com.cloud.utils.Pair;
import com.cloud.utils.component.Manager; import com.cloud.utils.component.Manager;
public interface ExtensionsManager extends 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); Map<String, Map<String, String>> getExternalAccessDetails(Host host, Map<String, String> vmDetails);
String handleExtensionServerCommands(ExtensionServerActionBaseCommand cmd); 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);
} }

View File

@ -1478,6 +1478,45 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana
return GsonHelper.getGson().toJson(answers); 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 @Override
public Long getExtensionIdForCluster(long clusterId) { public Long getExtensionIdForCluster(long clusterId) {
ExtensionResourceMapVO map = extensionResourceMapDao.findByResourceIdAndType(clusterId, ExtensionResourceMapVO map = extensionResourceMapDao.findByResourceIdAndType(clusterId,

View File

@ -1742,6 +1742,82 @@ public class ExtensionsManagerImplTest {
assertTrue(json.contains("\"result\":false")); 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 @Test
public void getExtensionIdForCluster_WhenMappingExists_ReturnsExtensionId() { public void getExtensionIdForCluster_WhenMappingExists_ReturnsExtensionId() {
long clusterId = 1L; long clusterId = 1L;

View File

@ -189,6 +189,7 @@ import com.cloud.storage.dao.VMTemplateDao;
import com.cloud.storage.dao.VolumeDao; import com.cloud.storage.dao.VolumeDao;
import com.cloud.user.Account; import com.cloud.user.Account;
import com.cloud.user.AccountManager; import com.cloud.user.AccountManager;
import com.cloud.utils.Pair;
import com.cloud.utils.StringUtils; import com.cloud.utils.StringUtils;
import com.cloud.utils.Ternary; import com.cloud.utils.Ternary;
import com.cloud.utils.UriUtils; import com.cloud.utils.UriUtils;
@ -223,8 +224,8 @@ import com.cloud.vm.VirtualMachineManager;
import com.cloud.vm.VirtualMachineProfile; import com.cloud.vm.VirtualMachineProfile;
import com.cloud.vm.VirtualMachineProfileImpl; import com.cloud.vm.VirtualMachineProfileImpl;
import com.cloud.vm.VmDetailConstants; import com.cloud.vm.VmDetailConstants;
import com.cloud.vm.dao.VMInstanceDetailsDao;
import com.cloud.vm.dao.VMInstanceDao; import com.cloud.vm.dao.VMInstanceDao;
import com.cloud.vm.dao.VMInstanceDetailsDao;
import com.google.gson.Gson; import com.google.gson.Gson;
@Component @Component
@ -1224,9 +1225,18 @@ public class ResourceManagerImpl extends ManagerBase implements ResourceManager,
String managedstate = cmd.getManagedstate(); String managedstate = cmd.getManagedstate();
String name = cmd.getClusterName(); String name = cmd.getClusterName();
CPU.CPUArch arch = cmd.getArch(); CPU.CPUArch arch = cmd.getArch();
final Map<String, String> externalDetails = cmd.getExternalDetails();
// Verify cluster information and update the cluster if needed // Verify cluster information and update the cluster if needed
boolean doUpdate = false; 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 (StringUtils.isNotBlank(name)) {
if(cluster.getHypervisorType() == HypervisorType.VMware) { if(cluster.getHypervisorType() == HypervisorType.VMware) {
@ -1311,6 +1321,11 @@ public class ResourceManagerImpl extends ManagerBase implements ResourceManager,
_clusterDao.update(cluster.getId(), cluster); _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 != null && !newManagedState.equals(oldManagedState)) {
if (newManagedState.equals(Managed.ManagedState.Unmanaged)) { if (newManagedState.equals(Managed.ManagedState.Unmanaged)) {
boolean success = false; boolean success = false;

View File

@ -94,7 +94,8 @@ export default {
}, },
methods: { methods: {
fetchData () { fetchData () {
if (!['cluster'].includes(this.$route.meta.name)) { if (!['cluster'].includes(this.$route.meta.name) || !this.resource.extensionid) {
this.extension = {}
return return
} }
this.loading = true this.loading = true

View File

@ -71,6 +71,14 @@
</a-select-option> </a-select-option>
</a-select> </a-select>
</a-form-item> </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"> <div :span="24" class="action-button">
<a-button :loading="loading" @click="onCloseAction">{{ $t('label.cancel') }}</a-button> <a-button :loading="loading" @click="onCloseAction">{{ $t('label.cancel') }}</a-button>
@ -84,11 +92,13 @@
import { ref, reactive, toRaw } from 'vue' import { ref, reactive, toRaw } from 'vue'
import { getAPI, postAPI } from '@/api' import { getAPI, postAPI } from '@/api'
import TooltipLabel from '@/components/widgets/TooltipLabel' import TooltipLabel from '@/components/widgets/TooltipLabel'
import DetailsInput from '@/components/widgets/DetailsInput'
export default { export default {
name: 'ClusterUpdate', name: 'ClusterUpdate',
components: { components: {
TooltipLabel TooltipLabel,
DetailsInput
}, },
props: { props: {
action: { action: {
@ -145,6 +155,7 @@ export default {
fetchData () { fetchData () {
this.fetchArchitectureTypes() this.fetchArchitectureTypes()
this.fetchStorageAccessGroupsData() this.fetchStorageAccessGroupsData()
this.fetchExtensionResourceMapDetails()
}, },
fetchArchitectureTypes () { fetchArchitectureTypes () {
this.architectureTypes.opts = [] this.architectureTypes.opts = []
@ -159,13 +170,39 @@ export default {
}) })
this.architectureTypes.opts = typesList 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 () { handleSubmit () {
this.formRef.value.validate().then(() => { this.formRef.value.validate().then(() => {
const values = toRaw(this.form) const values = toRaw(this.form)
console.log(values)
const params = {} const params = {}
params.id = this.resource.id params.id = this.resource.id
params.clustername = values.name params.clustername = values.name
if (values.externaldetails) {
Object.entries(values.externaldetails).forEach(([key, value]) => {
params['externaldetails[0].' + key] = value
})
}
this.loading = true this.loading = true
postAPI('updateCluster', params).then(json => { postAPI('updateCluster', params).then(json => {