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.
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());

View File

@ -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);
}

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

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