This is an automated email from the ASF dual-hosted git repository.

pearl11594 pushed a commit to branch dedicate-backup-offering-to-domain
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to 
refs/heads/dedicate-backup-offering-to-domain by this push:
     new 79ff42b0592 Add tests and UI support and update response params
79ff42b0592 is described below

commit 79ff42b0592a2864351344bb73e7fbf22cb06836
Author: Pearl Dsilva <[email protected]>
AuthorDate: Thu Dec 4 10:38:31 2025 -0500

    Add tests and UI support and update response params
---
 .../admin/backup/UpdateBackupOfferingCmd.java      | 11 +++-
 .../api/response/BackupOfferingResponse.java       | 17 +++++
 .../backup/dao/BackupOfferingDaoImpl.java          | 23 ++++++-
 .../cloudstack/backup/BackupManagerImpl.java       | 47 ++++++++++++-
 .../cloudstack/backup/BackupManagerTest.java       | 76 ++++++++++++++++++++++
 ui/src/config/section/offering.js                  |  6 +-
 ui/src/views/offering/ImportBackupOffering.vue     | 69 +++++++++++++++++++-
 7 files changed, 240 insertions(+), 9 deletions(-)

diff --git 
a/api/src/main/java/org/apache/cloudstack/api/command/admin/backup/UpdateBackupOfferingCmd.java
 
b/api/src/main/java/org/apache/cloudstack/api/command/admin/backup/UpdateBackupOfferingCmd.java
index 682f2f595ad..2b8643819d4 100644
--- 
a/api/src/main/java/org/apache/cloudstack/api/command/admin/backup/UpdateBackupOfferingCmd.java
+++ 
b/api/src/main/java/org/apache/cloudstack/api/command/admin/backup/UpdateBackupOfferingCmd.java
@@ -37,6 +37,7 @@ import com.cloud.user.Account;
 import com.cloud.utils.exception.CloudRuntimeException;
 
 import java.util.List;
+import java.util.function.LongFunction;
 
 @APICommand(name = "updateBackupOffering", description = "Updates a backup 
offering.", responseObject = BackupOfferingResponse.class,
 requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = 
"4.16.0")
@@ -114,7 +115,15 @@ public class UpdateBackupOfferingCmd extends BaseCmd 
implements DomainAndZoneIdR
     }
 
     public List<Long> getDomainIds() {
-        return resolveDomainIds(domainIds, id, 
backupManager::getBackupOfferingDomains, "backup offering");
+        // backupManager may be null in unit tests where the command is spied 
without injection.
+        // Avoid creating a method reference to a null receiver which causes 
NPE. When backupManager
+        // is null, pass null as the defaultDomainsProvider so 
resolveDomainIds will simply return
+        // an empty list or parse the explicit domainIds string.
+        LongFunction<List<Long>> defaultDomainsProvider = null;
+        if (backupManager != null) {
+            defaultDomainsProvider = backupManager::getBackupOfferingDomains;
+        }
+        return resolveDomainIds(domainIds, id, defaultDomainsProvider, "backup 
offering");
     }
 
     @Override
diff --git 
a/api/src/main/java/org/apache/cloudstack/api/response/BackupOfferingResponse.java
 
b/api/src/main/java/org/apache/cloudstack/api/response/BackupOfferingResponse.java
index 4120f68d9da..0669afa604b 100644
--- 
a/api/src/main/java/org/apache/cloudstack/api/response/BackupOfferingResponse.java
+++ 
b/api/src/main/java/org/apache/cloudstack/api/response/BackupOfferingResponse.java
@@ -61,6 +61,14 @@ public class BackupOfferingResponse extends BaseResponse {
     @Param(description = "zone name")
     private String zoneName;
 
+    @SerializedName(ApiConstants.DOMAIN_ID)
+    @Param(description = "the domain ID(s) this disk offering belongs to. 
Ignore this information as it is not currently applicable.")
+    private String domainId;
+
+    @SerializedName(ApiConstants.DOMAIN)
+    @Param(description = "the domain name(s) this disk offering belongs to. 
Ignore this information as it is not currently applicable.")
+    private String domain;
+
     @SerializedName(ApiConstants.CROSS_ZONE_INSTANCE_CREATION)
     @Param(description = "the backups with this offering can be used to create 
Instances on all Zones", since = "4.22.0")
     private Boolean crossZoneInstanceCreation;
@@ -108,4 +116,13 @@ public class BackupOfferingResponse extends BaseResponse {
     public void setCreated(Date created) {
         this.created = created;
     }
+
+    public void setDomainId(String domainId) {
+        this.domainId = domainId;
+    }
+
+    public void setDomain(String domain) {
+        this.domain = domain;
+    }
+
 }
diff --git 
a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupOfferingDaoImpl.java
 
b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupOfferingDaoImpl.java
index a41e4e70d33..708faeef464 100644
--- 
a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupOfferingDaoImpl.java
+++ 
b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupOfferingDaoImpl.java
@@ -20,6 +20,8 @@ package org.apache.cloudstack.backup.dao;
 import javax.annotation.PostConstruct;
 import javax.inject.Inject;
 
+import com.cloud.domain.DomainVO;
+import com.cloud.domain.dao.DomainDao;
 import org.apache.cloudstack.api.response.BackupOfferingResponse;
 import org.apache.cloudstack.backup.BackupOffering;
 import org.apache.cloudstack.backup.BackupOfferingVO;
@@ -30,10 +32,16 @@ import com.cloud.utils.db.GenericDaoBase;
 import com.cloud.utils.db.SearchBuilder;
 import com.cloud.utils.db.SearchCriteria;
 
+import java.util.List;
+
 public class BackupOfferingDaoImpl extends GenericDaoBase<BackupOfferingVO, 
Long> implements BackupOfferingDao {
 
     @Inject
     DataCenterDao dataCenterDao;
+    @Inject
+    BackupOfferingDetailsDao backupOfferingDetailsDao;
+    @Inject
+    DomainDao domainDao;
 
     private SearchBuilder<BackupOfferingVO> backupPoliciesSearch;
 
@@ -51,8 +59,9 @@ public class BackupOfferingDaoImpl extends 
GenericDaoBase<BackupOfferingVO, Long
 
     @Override
     public BackupOfferingResponse newBackupOfferingResponse(BackupOffering 
offering, Boolean crossZoneInstanceCreation) {
-        DataCenterVO zone = dataCenterDao.findById(offering.getZoneId());
 
+        DataCenterVO zone = dataCenterDao.findById(offering.getZoneId());
+        List<Long> domainIds = 
backupOfferingDetailsDao.findDomainIds(offering.getId());
         BackupOfferingResponse response = new BackupOfferingResponse();
         response.setId(offering.getUuid());
         response.setName(offering.getName());
@@ -64,6 +73,18 @@ public class BackupOfferingDaoImpl extends 
GenericDaoBase<BackupOfferingVO, Long
             response.setZoneId(zone.getUuid());
             response.setZoneName(zone.getName());
         }
+        if (domainIds != null && !domainIds.isEmpty()) {
+            String domainUUIDs = 
domainIds.stream().map(Long::valueOf).map(domainId -> {
+                DomainVO domain = domainDao.findById(domainId);
+                return domain != null ? domain.getUuid() : "";
+            }).filter(name -> !name.isEmpty()).reduce((a, b) -> a + "," + 
b).orElse("");
+            String domainNames = 
domainIds.stream().map(Long::valueOf).map(domainId -> {
+                DomainVO domain = domainDao.findById(domainId);
+                return domain != null ? domain.getName() : "";
+            }).filter(name -> !name.isEmpty()).reduce((a, b) -> a + "," + 
b).orElse("");
+            response.setDomain(domainNames);
+            response.setDomainId(domainUUIDs);
+        }
         if (crossZoneInstanceCreation) {
             response.setCrossZoneInstanceCreation(true);
         }
diff --git 
a/server/src/main/java/org/apache/cloudstack/backup/BackupManagerImpl.java 
b/server/src/main/java/org/apache/cloudstack/backup/BackupManagerImpl.java
index e1c51813ad6..2d9f7bbcc87 100644
--- a/server/src/main/java/org/apache/cloudstack/backup/BackupManagerImpl.java
+++ b/server/src/main/java/org/apache/cloudstack/backup/BackupManagerImpl.java
@@ -314,7 +314,7 @@ public class BackupManagerImpl extends ManagerBase 
implements BackupManager {
         if 
(org.apache.commons.collections.CollectionUtils.isNotEmpty(filteredDomainIds)) {
             List<BackupOfferingDetailsVO> detailsVOList = new ArrayList<>();
             for (Long domainId : filteredDomainIds) {
-                detailsVOList.add(new 
BackupOfferingDetailsVO(savedOffering.getId(), 
org.apache.cloudstack.api.ApiConstants.DOMAIN_ID, String.valueOf(domainId), 
false));
+                detailsVOList.add(new 
BackupOfferingDetailsVO(savedOffering.getId(), ApiConstants.DOMAIN_ID, 
String.valueOf(domainId), false));
             }
             if (!detailsVOList.isEmpty()) {
                 backupOfferingDetailsDao.saveDetails(detailsVOList);
@@ -2179,6 +2179,7 @@ public class BackupManagerImpl extends ManagerBase 
implements BackupManager {
         String name = updateBackupOfferingCmd.getName();
         String description = updateBackupOfferingCmd.getDescription();
         Boolean allowUserDrivenBackups = 
updateBackupOfferingCmd.getAllowUserDrivenBackups();
+        List<Long> domainIds = updateBackupOfferingCmd.getDomainIds();
 
         BackupOfferingVO backupOfferingVO = backupOfferingDao.findById(id);
         if (backupOfferingVO == null) {
@@ -2209,16 +2210,58 @@ public class BackupManagerImpl extends ManagerBase 
implements BackupManager {
             fields.add("allowUserDrivenBackups: " + allowUserDrivenBackups);
         }
 
-        if (!backupOfferingDao.update(id, offering)) {
+        if 
(org.apache.commons.collections.CollectionUtils.isNotEmpty(domainIds)) {
+            for (final Long domainId: domainIds) {
+                if (domainDao.findById(domainId) == null) {
+                    throw new InvalidParameterValueException("Please specify a 
valid domain id");
+                }
+            }
+        }
+        List<Long> filteredDomainIds = filterChildSubDomains(domainIds);
+        Collections.sort(filteredDomainIds);
+
+        boolean success =backupOfferingDao.update(id, offering);
+        if (!success) {
             logger.warn(String.format("Couldn't update Backup offering (%s) 
with [%s].", backupOfferingVO, String.join(", ", fields)));
         }
 
+        if (success) {
+            List<Long> existingDomainIds = 
backupOfferingDetailsDao.findDomainIds(id);
+            Collections.sort(existingDomainIds);
+            updateBackupOfferingDomainDetails(id, filteredDomainIds, 
existingDomainIds);
+        }
+
         BackupOfferingVO response = backupOfferingDao.findById(id);
         CallContext.current().setEventDetails(String.format("Backup Offering 
updated [%s].",
                 
ReflectionToStringBuilderUtils.reflectOnlySelectedFields(response, "id", 
"name", "description", "userDrivenBackupAllowed", "externalId")));
         return response;
     }
 
+    private void updateBackupOfferingDomainDetails(Long id, List<Long> 
filteredDomainIds, List<Long> existingDomainIds) {
+            if (existingDomainIds == null) {
+                existingDomainIds = new ArrayList<>();
+            }
+        List<BackupOfferingDetailsVO> detailsVO = new ArrayList<>();
+        if(!filteredDomainIds.equals(existingDomainIds)) {
+            SearchBuilder<BackupOfferingDetailsVO> sb = 
backupOfferingDetailsDao.createSearchBuilder();
+            sb.and("offeringId", sb.entity().getResourceId(), 
SearchCriteria.Op.EQ);
+            sb.and("detailName", sb.entity().getName(), SearchCriteria.Op.EQ);
+            sb.done();
+            SearchCriteria<BackupOfferingDetailsVO> sc = sb.create();
+            sc.setParameters("offeringId", String.valueOf(id));
+            sc.setParameters("detailName", ApiConstants.DOMAIN_ID);
+            backupOfferingDetailsDao.remove(sc);
+            for (Long domainId : filteredDomainIds) {
+                detailsVO.add(new BackupOfferingDetailsVO(id, 
ApiConstants.DOMAIN_ID, String.valueOf(domainId), false));
+            }
+        }
+        if (!detailsVO.isEmpty()) {
+            for (BackupOfferingDetailsVO detailVO : detailsVO) {
+                backupOfferingDetailsDao.persist(detailVO);
+            }
+        }
+    }
+
     Map<String, String> getDetailsFromBackupDetails(Long backupId) {
         Map<String, String> details = 
backupDetailsDao.listDetailsKeyPairs(backupId, true);
         if (details == null) {
diff --git 
a/server/src/test/java/org/apache/cloudstack/backup/BackupManagerTest.java 
b/server/src/test/java/org/apache/cloudstack/backup/BackupManagerTest.java
index 6f6879b6fe3..9b4661ff371 100644
--- a/server/src/test/java/org/apache/cloudstack/backup/BackupManagerTest.java
+++ b/server/src/test/java/org/apache/cloudstack/backup/BackupManagerTest.java
@@ -85,6 +85,7 @@ import org.apache.cloudstack.api.response.BackupResponse;
 import org.apache.cloudstack.backup.dao.BackupDao;
 import org.apache.cloudstack.backup.dao.BackupDetailsDao;
 import org.apache.cloudstack.backup.dao.BackupOfferingDao;
+import org.apache.cloudstack.backup.dao.BackupOfferingDetailsDao;
 import org.apache.cloudstack.backup.dao.BackupScheduleDao;
 import org.apache.cloudstack.context.CallContext;
 import org.apache.cloudstack.framework.config.ConfigKey;
@@ -241,6 +242,9 @@ public class BackupManagerTest {
     @Mock
     private GuestOSDao _guestOSDao;
 
+    @Mock
+    private BackupOfferingDetailsDao backupOfferingDetailsDao;
+
     private Gson gson;
 
     private String[] hostPossibleValues = {"127.0.0.1", "hostname"};
@@ -352,6 +356,7 @@ public class BackupManagerTest {
         when(cmd.getName()).thenReturn("New name");
         when(cmd.getDescription()).thenReturn("New description");
         when(cmd.getAllowUserDrivenBackups()).thenReturn(true);
+        
when(backupOfferingDetailsDao.findDomainIds(id)).thenReturn(Collections.emptyList());
 
         BackupOffering updated = backupManager.updateBackupOffering(cmd);
         assertEquals("New name", updated.getName());
@@ -2156,4 +2161,75 @@ public class BackupManagerTest {
         verify(vmInstanceDao, times(1)).findByIdIncludingRemoved(vmId);
         verify(volumeDao, times(1)).findByInstance(vmId);
     }
+
+    @Test
+    public void getBackupOfferingDomainsTestOfferingNotFound() {
+        Long offeringId = 1L;
+        when(backupOfferingDao.findById(offeringId)).thenReturn(null);
+
+        InvalidParameterValueException exception = 
Assert.assertThrows(InvalidParameterValueException.class,
+                () -> backupManager.getBackupOfferingDomains(offeringId));
+        assertEquals("Unable to find backup offering null", 
exception.getMessage());
+    }
+
+    @Test
+    public void getBackupOfferingDomainsTestReturnsDomains() {
+        Long offeringId = 1L;
+        BackupOfferingVO offering = Mockito.mock(BackupOfferingVO.class);
+        when(backupOfferingDao.findById(offeringId)).thenReturn(offering);
+        
when(backupOfferingDetailsDao.findDomainIds(offeringId)).thenReturn(List.of(10L,
 20L));
+
+        List<Long> result = backupManager.getBackupOfferingDomains(offeringId);
+
+        assertEquals(2, result.size());
+        assertTrue(result.contains(10L));
+        assertTrue(result.contains(20L));
+    }
+
+    @Test
+    public void testUpdateBackupOfferingThrowsWhenDomainIdInvalid() {
+        Long id = 1234L;
+        UpdateBackupOfferingCmd cmd = 
Mockito.spy(UpdateBackupOfferingCmd.class);
+        when(cmd.getId()).thenReturn(id);
+        when(cmd.getDomainIds()).thenReturn(List.of(99L));
+
+        when(domainDao.findById(99L)).thenReturn(null);
+
+        InvalidParameterValueException exception = 
Assert.assertThrows(InvalidParameterValueException.class,
+                () -> backupManager.updateBackupOffering(cmd));
+        assertEquals("Please specify a valid domain id", 
exception.getMessage());
+    }
+
+    @Test
+    public void testUpdateBackupOfferingPersistsDomainDetailsWhenProvided() {
+        Long id = 1234L;
+        Long domainId = 11L;
+        UpdateBackupOfferingCmd cmd = 
Mockito.spy(UpdateBackupOfferingCmd.class);
+        when(cmd.getId()).thenReturn(id);
+        when(cmd.getDomainIds()).thenReturn(List.of(domainId));
+
+        DomainVO domain = Mockito.mock(DomainVO.class);
+        when(domainDao.findById(domainId)).thenReturn(domain);
+
+        
when(backupOfferingDetailsDao.findDomainIds(id)).thenReturn(Collections.emptyList());
+
+        SearchBuilder<BackupOfferingDetailsVO> sb = 
Mockito.mock(SearchBuilder.class);
+        SearchCriteria<BackupOfferingDetailsVO> sc = 
Mockito.mock(SearchCriteria.class);
+        BackupOfferingDetailsVO entity = 
Mockito.mock(BackupOfferingDetailsVO.class);
+        when(backupOfferingDetailsDao.createSearchBuilder()).thenReturn(sb);
+        when(sb.entity()).thenReturn(entity);
+        when(sb.and(Mockito.anyString(), (Object) any(), 
Mockito.any())).thenReturn(sb);
+        when(sb.create()).thenReturn(sc);
+
+        BackupOfferingVO offering = Mockito.mock(BackupOfferingVO.class);
+        BackupOfferingVO offeringUpdate = Mockito.mock(BackupOfferingVO.class);
+        when(backupOfferingDao.findById(id)).thenReturn(offering);
+        when(backupOfferingDao.createForUpdate(id)).thenReturn(offeringUpdate);
+        when(backupOfferingDao.update(id, offeringUpdate)).thenReturn(true);
+
+        BackupOffering updated = backupManager.updateBackupOffering(cmd);
+
+        verify(backupOfferingDetailsDao, 
times(1)).persist(Mockito.any(BackupOfferingDetailsVO.class));
+    }
+
 }
diff --git a/ui/src/config/section/offering.js 
b/ui/src/config/section/offering.js
index 4a32619b8c2..bc95772d6f7 100644
--- a/ui/src/config/section/offering.js
+++ b/ui/src/config/section/offering.js
@@ -340,9 +340,9 @@ export default {
       icon: 'cloud-upload-outlined',
       docHelp: 'adminguide/virtual_machines.html#backup-offerings',
       permission: ['listBackupOfferings'],
-      searchFilters: ['zoneid'],
-      columns: ['name', 'description', 'zonename'],
-      details: ['name', 'id', 'description', 'externalid', 'zone', 
'allowuserdrivenbackups', 'created'],
+      searchFilters: ['zoneid', 'domainid'],
+      columns: ['name', 'description', 'domain', 'zonename'],
+      details: ['name', 'id', 'description', 'externalid', 'domain', 'zone', 
'allowuserdrivenbackups', 'created'],
       related: [{
         name: 'vm',
         title: 'label.instances',
diff --git a/ui/src/views/offering/ImportBackupOffering.vue 
b/ui/src/views/offering/ImportBackupOffering.vue
index b8ac7d8e8e6..f680eacd4a7 100644
--- a/ui/src/views/offering/ImportBackupOffering.vue
+++ b/ui/src/views/offering/ImportBackupOffering.vue
@@ -85,6 +85,33 @@
         </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" />
+        </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"
+            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">{{ 
this.$t('label.cancel') }}</a-button>
         <a-button :loading="loading" ref="submit" type="primary" 
@click="handleSubmit">{{ this.$t('label.ok') }}</a-button>
@@ -96,6 +123,7 @@
 <script>
 import { ref, reactive, toRaw } from 'vue'
 import { getAPI, postAPI } from '@/api'
+import { isAdmin } from '@/role'
 import ResourceIcon from '@/components/view/ResourceIcon'
 import TooltipLabel from '@/components/widgets/TooltipLabel'
 
@@ -108,6 +136,10 @@ export default {
   data () {
     return {
       loading: false,
+      domains: {
+        loading: false,
+        opts: []
+      },
       zones: {
         loading: false,
         opts: []
@@ -129,17 +161,23 @@ export default {
     initForm () {
       this.formRef = ref()
       this.form = reactive({
-        allowuserdrivenbackups: true
+        allowuserdrivenbackups: true,
+        ispublic: true
       })
       this.rules = reactive({
         name: [{ required: true, message: 
this.$t('message.error.required.input') }],
         description: [{ required: true, message: 
this.$t('message.error.required.input') }],
         zoneid: [{ required: true, message: this.$t('message.error.select') }],
-        externalid: [{ required: true, message: 
this.$t('message.error.select') }]
+        externalid: [{ required: true, message: 
this.$t('message.error.select') }],
+        domainid: [{ type: 'array', message: this.$t('message.error.select') }]
       })
     },
+    isAdmin () {
+      return isAdmin()
+    },
     fetchData () {
       this.fetchZone()
+      this.fetchDomainData()
     },
     fetchZone () {
       this.zones.loading = true
@@ -151,6 +189,19 @@ export default {
         this.zones.loading = false
       })
     },
+    fetchDomainData () {
+      const params = {}
+      params.listAll = true
+      params.details = 'min'
+      this.domains.loading = true
+      getAPI('listDomains', params).then(json => {
+        this.domains.opts = json.listdomainsresponse.domain || []
+      }).catch(error => {
+        this.$notifyError(error)
+      }).finally(() => {
+        this.domains.loading = false
+      })
+    },
     fetchExternal (zoneId) {
       if (!zoneId) {
         this.externals.opts = []
@@ -179,6 +230,20 @@ export default {
             params[key] = input
           }
         }
+        if (values.ispublic !== true) {
+          var domainIndexes = values.domainid
+          var domainId = null
+          if (domainIndexes && domainIndexes.length > 0) {
+            var domainIds = []
+            for (var i = 0; i < domainIndexes.length; i++) {
+              domainIds = 
domainIds.concat(this.domains.opts[domainIndexes[i]].id)
+            }
+            domainId = domainIds.join(',')
+          }
+          if (domainId) {
+            params.domainid = domainId
+          }
+        }
         params.allowuserdrivenbackups = values.allowuserdrivenbackups
         this.loading = true
         const title = this.$t('label.import.offering')

Reply via email to