add recently added domain id for bkp offering to be inherited in clone operation

This commit is contained in:
Pearl Dsilva 2026-01-21 16:57:19 -05:00
parent 3511525f36
commit 10333dfd0e
7 changed files with 270 additions and 4 deletions

View File

@ -26,6 +26,7 @@ import org.apache.cloudstack.api.BaseAsyncCmd;
import org.apache.cloudstack.api.BaseCmd;
import org.apache.cloudstack.api.Parameter;
import org.apache.cloudstack.api.ServerApiException;
import org.apache.cloudstack.api.command.offering.DomainAndZoneIdResolver;
import org.apache.cloudstack.api.response.BackupOfferingResponse;
import org.apache.cloudstack.api.response.ZoneResponse;
import org.apache.cloudstack.backup.BackupManager;
@ -41,11 +42,15 @@ import com.cloud.exception.ResourceAllocationException;
import com.cloud.exception.ResourceUnavailableException;
import com.cloud.utils.exception.CloudRuntimeException;
import java.util.Arrays;
import java.util.List;
import java.util.function.LongFunction;
@APICommand(name = "cloneBackupOffering",
description = "Clones a backup offering from an existing offering",
responseObject = BackupOfferingResponse.class, since = "4.14.0",
authorized = {RoleType.Admin})
public class CloneBackupOfferingCmd extends BaseAsyncCmd {
public class CloneBackupOfferingCmd extends BaseAsyncCmd implements DomainAndZoneIdResolver {
@Inject
protected BackupManager backupManager;
@ -74,6 +79,13 @@ public class CloneBackupOfferingCmd extends BaseAsyncCmd {
description = "The zone ID", required = false)
private Long zoneId;
@Parameter(name = ApiConstants.DOMAIN_ID,
type = CommandType.STRING,
description = "the ID of the containing domain(s) as comma separated string, public for public offerings",
since = "4.23.0",
length = 4096)
private String domainIds;
@Parameter(name = ApiConstants.ALLOW_USER_DRIVEN_BACKUPS, type = BaseCmd.CommandType.BOOLEAN,
description = "Whether users are allowed to create adhoc backups and backup schedules", required = false)
private Boolean userDrivenBackups;
@ -106,6 +118,17 @@ public class CloneBackupOfferingCmd extends BaseAsyncCmd {
return userDrivenBackups;
}
public List<Long> getDomainIds() {
if (domainIds != null && !domainIds.isEmpty()) {
return Arrays.asList(Arrays.stream(domainIds.split(",")).map(domainId -> Long.parseLong(domainId.trim())).toArray(Long[]::new));
}
LongFunction<List<Long>> defaultDomainsProvider = null;
if (backupManager != null) {
defaultDomainsProvider = backupManager::getBackupOfferingDomains;
}
return resolveDomainIds(domainIds, sourceOfferingId, defaultDomainsProvider, "backup offering");
}
/////////////////////////////////////////////////////
/////////////// API Implementation///////////////////
/////////////////////////////////////////////////////

View File

@ -86,7 +86,8 @@ public class ImportBackupOfferingCmd extends BaseAsyncCmd {
type = CommandType.LIST,
collectionType = CommandType.UUID,
entityType = DomainResponse.class,
description = "the ID of the containing domain(s), null for public offerings")
description = "the ID of the containing domain(s), null for public offerings",
since = "4.23.0")
private List<Long> domainIds;
/////////////////////////////////////////////////////

View File

@ -22,6 +22,7 @@ import static com.cloud.offering.NetworkOffering.RoutingMode.Static;
import static org.apache.cloudstack.framework.config.ConfigKey.CATEGORY_SYSTEM;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Field;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLDecoder;
@ -8663,7 +8664,7 @@ public class ConfigurationManagerImpl extends ManagerBase implements Configurati
}
public static void setField(Object obj, String fieldName, Object value) throws Exception {
java.lang.reflect.Field field = findField(obj.getClass(), fieldName);
Field field = findField(obj.getClass(), fieldName);
if (field == null) {
throw new NoSuchFieldException("Field '" + fieldName + "' not found in class hierarchy of " + obj.getClass().getName());
}
@ -8671,7 +8672,7 @@ public class ConfigurationManagerImpl extends ManagerBase implements Configurati
field.set(obj, value);
}
public static java.lang.reflect.Field findField(Class<?> clazz, String fieldName) {
public static Field findField(Class<?> clazz, String fieldName) {
Class<?> currentClass = clazz;
while (currentClass != null) {
try {

View File

@ -372,10 +372,33 @@ public class BackupManagerImpl extends ManagerBase implements BackupManager {
throw new CloudRuntimeException("Unable to clone backup offering from ID: " + cmd.getSourceOfferingId());
}
List<Long> filteredDomainIds = cmd.getDomainIds() == null ? new ArrayList<>() : new ArrayList<>(cmd.getDomainIds());
Collections.sort(filteredDomainIds);
updateBackupOfferingDomainDetail(savedOffering, filteredDomainIds);
logger.debug("Successfully cloned backup offering '" + sourceOffering.getName() + "' (ID: " + cmd.getSourceOfferingId() + ") to '" + cmd.getName() + "' (ID: " + savedOffering.getId() + ")");
return savedOffering;
}
private void updateBackupOfferingDomainDetail(BackupOfferingVO savedOffering, List<Long> filteredDomainIds) {
if (filteredDomainIds.size() > 1) {
filteredDomainIds = domainHelper.filterChildSubDomains(filteredDomainIds);
}
if (CollectionUtils.isNotEmpty(filteredDomainIds)) {
List<BackupOfferingDetailsVO> detailsVOList = new ArrayList<>();
for (Long domainId : filteredDomainIds) {
if (domainDao.findById(domainId) == null) {
throw new InvalidParameterValueException("Please specify a valid domain id");
}
detailsVOList.add(new BackupOfferingDetailsVO(savedOffering.getId(), ApiConstants.DOMAIN_ID, String.valueOf(domainId), false));
}
if (!detailsVOList.isEmpty()) {
backupOfferingDetailsDao.saveDetails(detailsVOList);
}
}
}
@Override
public List<Long> getBackupOfferingDomains(Long offeringId) {
final BackupOffering backupOffering = backupOfferingDao.findById(offeringId);

View File

@ -76,6 +76,7 @@ import com.cloud.vm.dao.VMInstanceDao;
import com.google.gson.Gson;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.ServerApiException;
import org.apache.cloudstack.api.command.admin.backup.CloneBackupOfferingCmd;
import org.apache.cloudstack.api.command.admin.backup.ImportBackupOfferingCmd;
import org.apache.cloudstack.api.command.admin.backup.UpdateBackupOfferingCmd;
import org.apache.cloudstack.api.command.user.backup.CreateBackupCmd;
@ -132,6 +133,7 @@ import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.atLeastOnce;
import org.mockito.ArgumentCaptor;
@RunWith(MockitoJUnitRunner.class)
public class BackupManagerTest {
@ -2518,4 +2520,106 @@ public class BackupManagerTest {
return offering;
}
@Test
public void testCloneBackupOfferingUsesProvidedDomainIds() {
Long sourceOfferingId = 1L;
Long zoneId = 10L;
Long savedOfferingId = 2L;
List<Long> providedDomainIds = List.of(11L);
// command
CloneBackupOfferingCmd cmd = Mockito.mock(CloneBackupOfferingCmd.class);
when(cmd.getSourceOfferingId()).thenReturn(sourceOfferingId);
when(cmd.getName()).thenReturn("Cloned Offering");
when(cmd.getDescription()).thenReturn(null);
when(cmd.getExternalId()).thenReturn(null);
when(cmd.getUserDrivenBackups()).thenReturn(null);
when(cmd.getDomainIds()).thenReturn(providedDomainIds);
// source offering
BackupOfferingVO sourceOffering = Mockito.mock(BackupOfferingVO.class);
when(sourceOffering.getZoneId()).thenReturn(zoneId);
when(sourceOffering.getExternalId()).thenReturn("ext-src");
when(sourceOffering.getProvider()).thenReturn("testbackupprovider");
when(sourceOffering.getDescription()).thenReturn("src desc");
when(sourceOffering.isUserDrivenBackupAllowed()).thenReturn(true);
when(sourceOffering.getName()).thenReturn("Source Offering");
when(backupOfferingDao.findById(sourceOfferingId)).thenReturn(sourceOffering);
when(backupOfferingDao.findByName(cmd.getName(), zoneId)).thenReturn(null);
BackupOfferingVO savedOffering = Mockito.mock(BackupOfferingVO.class);
when(savedOffering.getId()).thenReturn(savedOfferingId);
when(backupOfferingDao.persist(any(BackupOfferingVO.class))).thenReturn(savedOffering);
DomainVO domain = Mockito.mock(DomainVO.class);
when(domainDao.findById(11L)).thenReturn(domain);
overrideBackupFrameworkConfigValue();
BackupOffering result = backupManager.cloneBackupOffering(cmd);
assertEquals(savedOffering, result);
ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
verify(backupOfferingDetailsDao, times(1)).saveDetails(captor.capture());
List<BackupOfferingDetailsVO> savedDetails = captor.getValue();
assertEquals(1, savedDetails.size());
assertEquals(String.valueOf(11L), savedDetails.get(0).getValue());
}
@Test
public void testCloneBackupOfferingInheritsDomainIdsFromSource() {
Long sourceOfferingId = 3L;
Long zoneId = 20L;
Long savedOfferingId = 4L;
List<Long> sourceDomainIds = List.of(21L, 22L);
CloneBackupOfferingCmd cmd = Mockito.mock(CloneBackupOfferingCmd.class);
when(cmd.getSourceOfferingId()).thenReturn(sourceOfferingId);
when(cmd.getName()).thenReturn("Cloned Inherit Offering");
when(cmd.getDescription()).thenReturn(null);
when(cmd.getExternalId()).thenReturn(null);
when(cmd.getUserDrivenBackups()).thenReturn(null);
// Simulate resolver having provided the source offering domains (the real cmd#getDomainIds() would do this)
when(cmd.getDomainIds()).thenReturn(sourceDomainIds);
BackupOfferingVO sourceOffering = Mockito.mock(BackupOfferingVO.class);
when(sourceOffering.getZoneId()).thenReturn(zoneId);
when(sourceOffering.getExternalId()).thenReturn("ext-src-2");
when(sourceOffering.getProvider()).thenReturn("testbackupprovider");
when(sourceOffering.getDescription()).thenReturn("src desc 2");
when(sourceOffering.isUserDrivenBackupAllowed()).thenReturn(false);
when(sourceOffering.getName()).thenReturn("Source Offering 2");
when(backupOfferingDao.findById(sourceOfferingId)).thenReturn(sourceOffering);
when(backupOfferingDao.findByName(cmd.getName(), zoneId)).thenReturn(null);
BackupOfferingVO savedOffering = Mockito.mock(BackupOfferingVO.class);
when(savedOffering.getId()).thenReturn(savedOfferingId);
when(backupOfferingDao.persist(any(BackupOfferingVO.class))).thenReturn(savedOffering);
// domain handling
DomainVO domain21 = Mockito.mock(DomainVO.class);
DomainVO domain22 = Mockito.mock(DomainVO.class);
when(domainDao.findById(21L)).thenReturn(domain21);
when(domainDao.findById(22L)).thenReturn(domain22);
when(domainHelper.filterChildSubDomains(sourceDomainIds)).thenReturn(new ArrayList<>(sourceDomainIds));
overrideBackupFrameworkConfigValue();
BackupOffering result = backupManager.cloneBackupOffering(cmd);
assertEquals(savedOffering, result);
ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
verify(backupOfferingDetailsDao, times(1)).saveDetails(captor.capture());
List<BackupOfferingDetailsVO> savedDetails = captor.getValue();
assertEquals(2, savedDetails.size());
List<String> values = new ArrayList<>();
for (BackupOfferingDetailsVO d : savedDetails) {
values.add(d.getValue());
}
assertTrue(values.contains(String.valueOf(21L)));
assertTrue(values.contains(String.valueOf(22L)));
}
}

View File

@ -3357,6 +3357,7 @@
"message.disable.webhook.ssl.verification": "Disabling SSL verification is not recommended",
"message.discovering.feature": "Discovering features, please wait...",
"message.disk.offering.created": "Disk offering created:",
"message.success.clone.backup.offering": "Successfully cloned backup offering",
"message.success.clone.disk.offering": "Successfully cloned disk offering:",
"message.success.clone.network.offering": "Successfully cloned network offering:",
"message.disk.usage.info.data.points": "Each data point represents the difference in read/write data since the last data point.",

View File

@ -101,6 +101,34 @@
</template>
<a-switch v-model:checked="form.allowuserdrivenbackups"/>
</a-form-item>
<a-form-item name="ispublic" ref="ispublic" :label="$t('label.ispublic')" v-if="isAdmin()">
<a-switch v-model:checked="form.ispublic" @change="onChangeIsPublic" />
</a-form-item>
<a-form-item name="domainid" ref="domainid" v-if="!form.ispublic">
<template #label>
<tooltip-label :title="$t('label.domainid')" :tooltip="apiParams.domainid.description"/>
</template>
<a-select
mode="multiple"
:getPopupContainer="(trigger) => trigger.parentNode"
v-model:value="form.domainid"
@change="onChangeDomain"
showSearch
optionFilterProp="label"
:filterOption="(input, option) => {
return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
}"
:loading="domains.loading"
:placeholder="apiParams.domainid.description">
<a-select-option v-for="(opt, optIndex) in domains.opts" :key="optIndex" :label="opt.path || opt.name || opt.description">
<span>
<resource-icon v-if="opt && opt.icon" :image="opt.icon.base64image" size="1x" style="margin-right: 5px"/>
<block-outlined v-else style="margin-right: 5px" />
{{ opt.path || opt.name || opt.description }}
</span>
</a-select-option>
</a-select>
</a-form-item>
<div :span="24" class="action-button">
<a-button :loading="loading" @click="closeAction">{{ $t('label.cancel') }}</a-button>
<a-button :loading="loading" ref="submit" type="primary" @click="handleSubmit">{{ $t('label.ok') }}</a-button>
@ -116,6 +144,7 @@ import { getAPI, postAPI } from '@/api'
import ResourceIcon from '@/components/view/ResourceIcon'
import TooltipLabel from '@/components/widgets/TooltipLabel'
import { GlobalOutlined } from '@ant-design/icons-vue'
import { isAdmin } from '@/role'
export default {
name: 'CloneBackupOffering',
@ -140,6 +169,10 @@ export default {
externals: {
loading: false,
opts: []
},
domains: {
loading: false,
opts: []
}
}
},
@ -163,6 +196,7 @@ export default {
},
fetchData () {
this.fetchZone()
this.fetchDomain()
this.$nextTick(() => {
this.populateFormFromResource()
})
@ -180,6 +214,20 @@ export default {
this.zones.loading = false
})
},
fetchDomain () {
this.domains.loading = true
const params = { listAll: true, showicon: true, details: 'min' }
getAPI('listDomains', params).then(json => {
this.domains.opts = json.listdomainsresponse.domain || []
this.$nextTick(() => {
this.populateFormFromResource()
})
}).catch(error => {
this.$notifyError(error)
}).finally(() => {
this.domains.loading = false
})
},
fetchExternal (zoneId) {
if (!zoneId) {
this.externals.opts = []
@ -206,6 +254,27 @@ export default {
this.form.allowuserdrivenbackups = r.allowuserdrivenbackups
}
if (r.domainid) {
let offeringDomainIds = r.domainid
offeringDomainIds = (typeof offeringDomainIds === 'string' && offeringDomainIds.indexOf(',') !== -1) ? offeringDomainIds.split(',') : [offeringDomainIds]
const selected = []
for (let i = 0; i < offeringDomainIds.length; i++) {
for (let j = 0; j < this.domains.opts.length; j++) {
if (String(offeringDomainIds[i]) === String(this.domains.opts[j].id)) {
selected.push(j)
}
}
}
if (selected.length > 0) {
this.form.ispublic = false
this.form.domainid = selected
}
} else {
if (isAdmin()) {
this.form.ispublic = true
}
}
if (r.zoneid && this.zones.opts.length > 0) {
const zone = this.zones.opts.find(z => z.id === r.zoneid)
if (zone) {
@ -255,6 +324,23 @@ export default {
params.allowuserdrivenbackups = values.allowuserdrivenbackups
}
// Include selected domain IDs when offering is not public
if (values.ispublic !== true) {
const domainIndexes = values.domainid
let domainId = null
if (domainIndexes && domainIndexes.length > 0) {
const domainIds = []
const domains = this.domains.opts
for (let i = 0; i < domainIndexes.length; i++) {
domainIds.push(domains[domainIndexes[i]].id)
}
domainId = domainIds.join(',')
}
if (domainId) {
params.domainid = domainId
}
}
this.loading = true
const title = this.$t('message.success.clone.backup.offering')
@ -301,8 +387,35 @@ export default {
this.fetchExternal(zone.id)
}
},
onChangeIsPublic (value) {
// when made public, clear any domain selection
try {
if (value === true) {
this.form.domainid = []
}
} catch (e) {
// ignore
}
},
onChangeDomain (value) {
// value is an array of selected indexes (as used in the form), when empty -> make offering public
try {
if (!value || (Array.isArray(value) && value.length === 0)) {
this.form.ispublic = true
// clear domain selection to avoid confusing hidden inputs
this.form.domainid = []
} else {
this.form.ispublic = false
}
} catch (e) {
// defensive - do nothing on error
}
},
closeAction () {
this.$emit('close-action')
},
isAdmin () {
return isAdmin()
}
}
}