introduce 'unmanage' param in deleteDnsServerCmd to prevent deletion of zones from dns provider

This commit is contained in:
Manoj Kumar 2026-05-01 17:42:40 +05:30
parent 9391ea3fd2
commit 8f71ce6f40
No known key found for this signature in database
GPG Key ID: E952B7234D2C6F88
12 changed files with 124 additions and 55 deletions

View File

@ -68,7 +68,7 @@ public class CreateDnsZoneCmd extends BaseAsyncCreateCmd {
@Parameter(name = ApiConstants.EXISTING, type = CommandType.BOOLEAN, entityType = DnsZoneResponse.class,
description = "If true, imports an existing DNS zone from the DNS provider into CloudStack. " +
"If false, creates the zone in the DNS provider and registers it in CloudStack.")
"If false, creates the zone in the DNS provider and registers it in CloudStack. Default is false")
private Boolean existing = false;
/////////////////////////////////////////////////////

View File

@ -53,9 +53,15 @@ public class DeleteDnsServerCmd extends BaseAsyncCmd {
private Long id;
@Parameter(name = ApiConstants.CLEANUP, type = CommandType.BOOLEAN,
entityType = DnsZoneResponse.class, description = "True if all associated DNS zones have to be cleaned up with this server")
entityType = DnsZoneResponse.class, description = "If true, all associated DNS zones will be cleaned up " +
"when the server is removed. Default: true")
private Boolean cleanup = true;
@Parameter(name = ApiConstants.UNMANAGE, type = CommandType.BOOLEAN, entityType = DnsZoneResponse.class,
description = "If true, the DNS zone is only removed from CloudStack (unmanaged); if false, it is removed " +
"from both CloudStack and the DNS provider. Default: false")
private Boolean unmanage = false;
/////////////////////////////////////////////////////
/////////////////// Accessors ///////////////////////
/////////////////////////////////////////////////////
@ -102,4 +108,8 @@ public class DeleteDnsServerCmd extends BaseAsyncCmd {
public Boolean getCleanup() {
return Boolean.TRUE.equals(cleanup);
}
public Boolean isUnmanage() {
return Boolean.TRUE.equals(unmanage);
}
}

View File

@ -52,8 +52,8 @@ public class DeleteDnsZoneCmd extends BaseAsyncCmd {
private Long id;
@Parameter(name = ApiConstants.UNMANAGE, type = CommandType.BOOLEAN, entityType = DnsZoneResponse.class,
description = "If true, removes the DNS zone from CloudStack; if false, " +
"removes it from both CloudStack and the DNS provider.")
description = "If true, imports an existing DNS zone from the DNS provider into CloudStack; " +
"if false, creates the DNS zone in the provider and registers it with CloudStack. Default: false")
private Boolean unmanage = false;
/////////////////////////////////////////////////////
@ -79,7 +79,7 @@ public class DeleteDnsZoneCmd extends BaseAsyncCmd {
throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to delete DNS Zone");
}
} catch (Exception e) {
throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to delete DNS Zone: " + e.getMessage());
throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage());
}
}

View File

@ -16,7 +16,7 @@
// under the License.
package com.cloud.vm.dao;
import java.util.List;
import java.util.Set;
import org.apache.cloudstack.resourcedetail.ResourceDetailsDao;
@ -25,5 +25,5 @@ import com.cloud.vm.NicDetailVO;
public interface NicDetailsDao extends GenericDao<NicDetailVO, Long>, ResourceDetailsDao<NicDetailVO> {
void removeDetailsForValuesIn(String resourceName, List<String> values);
void removeDetailsForNicIds(String resourceName, Set<Long> nicIds);
}

View File

@ -17,28 +17,27 @@
package com.cloud.vm.dao;
import java.util.List;
import java.util.Set;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.resourcedetail.ResourceDetailsDaoBase;
import org.apache.commons.collections.CollectionUtils;
import org.springframework.stereotype.Component;
import org.apache.cloudstack.resourcedetail.ResourceDetailsDaoBase;
import com.cloud.utils.db.SearchBuilder;
import com.cloud.utils.db.SearchCriteria;
import com.cloud.vm.NicDetailVO;
@Component
public class NicDetailsDaoImpl extends ResourceDetailsDaoBase<NicDetailVO> implements NicDetailsDao {
private final SearchBuilder<NicDetailVO> NameValuesSearch;
private final SearchBuilder<NicDetailVO> ResourceIdNameSearch;
public NicDetailsDaoImpl() {
super();
NameValuesSearch = createSearchBuilder();
NameValuesSearch.and(ApiConstants.NAME, NameValuesSearch.entity().getName(), SearchCriteria.Op.EQ);
NameValuesSearch.and(ApiConstants.VALUE, NameValuesSearch.entity().getValue(), SearchCriteria.Op.IN);
NameValuesSearch.done();
ResourceIdNameSearch = createSearchBuilder();
ResourceIdNameSearch.and(ApiConstants.NAME, ResourceIdNameSearch.entity().getName(), SearchCriteria.Op.EQ);
ResourceIdNameSearch.and(ApiConstants.RESOURCE_ID, ResourceIdNameSearch.entity().getResourceId(), SearchCriteria.Op.IN);
ResourceIdNameSearch.done();
}
@ -48,13 +47,13 @@ public class NicDetailsDaoImpl extends ResourceDetailsDaoBase<NicDetailVO> imple
}
@Override
public void removeDetailsForValuesIn(String resourceName, List<String> values) {
if (CollectionUtils.isEmpty(values)) {
public void removeDetailsForNicIds(String resourceName, Set<Long> nicIds) {
if (CollectionUtils.isEmpty(nicIds)) {
return;
}
SearchCriteria<NicDetailVO> sc = NameValuesSearch.create();
SearchCriteria<NicDetailVO> sc = ResourceIdNameSearch.create();
sc.setParameters(ApiConstants.NAME, resourceName);
sc.setParameters(ApiConstants.VALUE, values.toArray());
sc.setParameters(ApiConstants.RESOURCE_ID, nicIds.toArray());
remove(sc);
}
}

View File

@ -37,4 +37,4 @@ FROM
LEFT JOIN
`cloud`.`nic_details` nd ON n.id = nd.nic_id AND nd.name = 'nicdnsname'
WHERE
map.removed IS NULL;
n.instance_id IS NOT NULL AND map.removed IS NULL;

View File

@ -28,7 +28,6 @@ import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@ -316,7 +315,7 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa
List<DnsZoneVO> dnsZones = dnsZoneDao.findDnsZonesByServerId(dnsServerId);
for (DnsZoneVO dnsZone : dnsZones) {
try {
deleteDnsZone(dnsZone.getId(), false);
deleteDnsZone(dnsZone.getId(), cmd.isUnmanage());
} catch (Exception ex) {
logger.error("Error cleaning up DNS zone: {} during DNS server: {} deletion", dnsZone.getName(), dnsServer.getName());
}
@ -328,7 +327,7 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa
@Override
@ActionEvent(eventType = EventTypes.EVENT_DNS_ZONE_DELETE, eventDescription = "Deleting DNS Zone")
public boolean deleteDnsZone(Long zoneId, boolean isUnmanage) {
public boolean deleteDnsZone(Long zoneId, boolean retainInProvider) {
DnsZoneVO dnsZone = dnsZoneDao.findById(zoneId);
if (dnsZone == null) {
throw new InvalidParameterValueException("DNS zone not found for the given ID.");
@ -336,23 +335,16 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa
String dnsZoneName = dnsZone.getName();
Account caller = CallContext.current().getCallingAccount();
accountMgr.checkAccess(caller, null, true, dnsZone);
DnsServerVO server = dnsServerDao.findById(dnsZone.getDnsServerId());
if (server == null) {
throw new CloudRuntimeException(String.format("The DNS server not found for DNS zone: %s", dnsZoneName));
}
boolean dbResult = Transaction.execute((TransactionCallback<Boolean>) status -> {
DnsZoneNetworkMapVO networkMapVO = dnsZoneNetworkMapDao.findByZoneId(zoneId);
DnsProvider provider = getProviderByType(server.getProviderType());
// Remove DNS records from nic_details if there are any
if (networkMapVO != null) {
try {
List<DnsRecord> records = provider.listRecords(server, dnsZone);
if (CollectionUtils.isNotEmpty(records)) {
List<String> dnsRecordNames = records.stream().map(DnsRecord::getName).filter(Objects::nonNull)
.map(name -> name.replaceAll("\\.+$", ""))
.collect(Collectors.toList());
nicDetailsDao.removeDetailsForValuesIn(ApiConstants.NIC_DNS_NAME, dnsRecordNames);
List<NicDnsJoinVO> nicDnsJoinVOS = nicDnsJoinDao.listByZoneId(zoneId);
if (CollectionUtils.isNotEmpty(nicDnsJoinVOS)) {
Set<Long> nicIds = nicDnsJoinVOS.stream().map(NicDnsJoinVO::getId).collect(Collectors.toSet());
nicDetailsDao.removeDetailsForNicIds(ApiConstants.NIC_DNS_NAME, nicIds);
}
} catch (Exception ex) {
logger.warn("Failed to fetch DNS records for dnsZone: {}, perform manual cleanup.", dnsZoneName, ex);
@ -361,14 +353,22 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa
}
// Remove DNS zone from provider and cleanup DB
if (!isUnmanage) {
if (!retainInProvider) {
try {
DnsServerVO server = dnsServerDao.findById(dnsZone.getDnsServerId());
if (server == null) {
throw new CloudRuntimeException(String.format("The DNS server not found for DNS zone: %s", dnsZoneName));
}
DnsProvider provider = getProviderByType(server.getProviderType());
provider.deleteZone(server, dnsZone);
logger.debug("Deleted DNS zone: {} from provider", dnsZoneName);
} catch (DnsNotFoundException ex) {
logger.warn("DNS zone: {} is not present in the provider, proceeding with cleanup", dnsZoneName);
} catch (Exception ex) {
logger.error("Failed to delete DNS zone from provider", ex);
if (ex instanceof CloudRuntimeException) {
throw new CloudRuntimeException(ex.getMessage());
}
throw new CloudRuntimeException(String.format("Failed to delete DNS zone: %s.", dnsZoneName));
}
}
@ -1151,7 +1151,7 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa
}
private boolean isDnsCollision(String dnsRecordUrl, long targetZoneId, long instanceId) {
NicDnsJoinVO existing = nicDnsJoinDao.findActiveByDnsRecordAndZone(dnsRecordUrl, targetZoneId);
NicDnsJoinVO existing = nicDnsJoinDao.findActiveByDnsRecordAndZone(targetZoneId, dnsRecordUrl);
if (existing != null && existing.getInstanceId() != instanceId) {
logger.error("DNS collision: cannot register DNS record: {}. Already owned by Instance: {}.",
dnsRecordUrl, existing.getInstanceId());

View File

@ -173,6 +173,9 @@ public class DnsServerDaoImpl extends GenericDaoBase<DnsServerVO, Long> implemen
@Override
public void loadDetails(DnsServer dnsServer) {
if (dnsServer == null) {
return;
}
Map<String, String> details = dnsServerDetailsDao.listDetailsKeyPairs(dnsServer.getId());
dnsServer.setDetails(details);
}

View File

@ -27,20 +27,22 @@ public interface NicDnsJoinDao extends GenericDao<NicDnsJoinVO, Long> {
/**
* Used for Collision Checks.
* @param dnsRecordUrl
*
* @param dnsZoneId
* @param nicDnsName
* @return active records to see who currently owns the dnsRecordUrl.
*/
NicDnsJoinVO findActiveByDnsRecordAndZone(String dnsRecordUrl, long dnsZoneId);
NicDnsJoinVO findActiveByDnsRecordAndZone(long dnsZoneId, String nicDnsName);
/**
* Used to sync DNS record url based on available ips for vmId in the dnsZone
*
* @param vmId
* @param dnsZoneId
* @param dnsRecordUrl
* @param nicDnsName
* @return list of active nics using the dnsRecordUrl, supports null vmId for dnsZone wide query
*/
List<NicDnsJoinVO> listActiveByVmIdZoneAndDnsRecord(Long vmId, long dnsZoneId, String dnsRecordUrl);
List<NicDnsJoinVO> listActiveByVmIdZoneAndDnsRecord(Long vmId, long dnsZoneId, String nicDnsName);
/**
* Used for VM Start/Running
@ -55,4 +57,11 @@ public interface NicDnsJoinDao extends GenericDao<NicDnsJoinVO, Long> {
* @return records with soft-delete
*/
List<NicDnsJoinVO> listIncludingRemovedByVmId(long vmId);
/**
* Find all records for dnsZoneId with valid nicDnsName
* @param dnsZoneId
* @return
*/
List<NicDnsJoinVO> listByZoneId(long dnsZoneId);
}

View File

@ -30,6 +30,7 @@ public class NicDnsJoinDaoImpl extends GenericDaoBase<NicDnsJoinVO, Long> implem
private final SearchBuilder<NicDnsJoinVO> activeDnsRecordZoneSearch;
private final SearchBuilder<NicDnsJoinVO> activeVmZoneDnsRecordSearch; // Route for null vmId
private final SearchBuilder<NicDnsJoinVO> activeVmSearch;
private final SearchBuilder<NicDnsJoinVO> activeDnsRecordsByZoneIdSearch;
public NicDnsJoinDaoImpl() {
@ -49,28 +50,36 @@ public class NicDnsJoinDaoImpl extends GenericDaoBase<NicDnsJoinVO, Long> implem
activeVmSearch = createSearchBuilder();
activeVmSearch.and(ApiConstants.INSTANCE_ID, activeVmSearch.entity().getInstanceId(), SearchCriteria.Op.EQ);
activeVmSearch.done();
activeDnsRecordsByZoneIdSearch = createSearchBuilder();
activeDnsRecordsByZoneIdSearch.selectFields(activeDnsRecordsByZoneIdSearch.entity().getId());
activeDnsRecordsByZoneIdSearch.and(ApiConstants.DNS_ZONE_ID, activeDnsRecordsByZoneIdSearch.entity().getDnsZoneId(), SearchCriteria.Op.EQ);
activeDnsRecordsByZoneIdSearch.and(ApiConstants.NIC_DNS_NAME, activeDnsRecordsByZoneIdSearch.entity().getNicDnsName(), SearchCriteria.Op.NNULL);
activeDnsRecordsByZoneIdSearch.and(ApiConstants.REMOVED, activeDnsRecordsByZoneIdSearch.entity().getRemoved(), SearchCriteria.Op.NULL);
activeDnsRecordsByZoneIdSearch.done();
}
@Override
public NicDnsJoinVO findActiveByDnsRecordAndZone(String dnsRecordUrl, long dnsZoneId) {
public NicDnsJoinVO findActiveByDnsRecordAndZone(long dnsZoneId, String nicDnsName) {
SearchCriteria<NicDnsJoinVO> sc = activeDnsRecordZoneSearch.create();
sc.setParameters(ApiConstants.NIC_DNS_NAME, dnsRecordUrl);
sc.setParameters(ApiConstants.NIC_DNS_NAME, nicDnsName);
sc.setParameters(ApiConstants.DNS_ZONE_ID, dnsZoneId);
return findOneBy(sc);
}
@Override
public List<NicDnsJoinVO> listActiveByVmIdZoneAndDnsRecord(Long vmId, long dnsZoneId, String dnsRecordUrl) {
public List<NicDnsJoinVO> listActiveByVmIdZoneAndDnsRecord(Long vmId, long dnsZoneId, String nicDnsName) {
if (vmId != null) {
SearchCriteria<NicDnsJoinVO> sc = activeDnsRecordZoneSearch.create();
SearchCriteria<NicDnsJoinVO> sc = activeVmZoneDnsRecordSearch.create();
sc.setParameters(ApiConstants.INSTANCE_ID, vmId);
sc.setParameters(ApiConstants.DNS_ZONE_ID, dnsZoneId);
sc.setParameters(ApiConstants.NIC_DNS_NAME, dnsRecordUrl);
sc.setParameters(ApiConstants.NIC_DNS_NAME, nicDnsName);
return listBy(sc);
} else {
SearchCriteria<NicDnsJoinVO> sc = activeDnsRecordZoneSearch.create();
sc.setParameters(ApiConstants.NIC_DNS_NAME, dnsRecordUrl);
sc.setParameters(ApiConstants.DNS_ZONE_ID, dnsZoneId);
sc.setParameters(ApiConstants.NIC_DNS_NAME, nicDnsName);
return listBy(sc);
}
}
@ -88,4 +97,11 @@ public class NicDnsJoinDaoImpl extends GenericDaoBase<NicDnsJoinVO, Long> implem
sc.setParameters(ApiConstants.INSTANCE_ID, vmId);
return listIncludingRemovedBy(sc);
}
@Override
public List<NicDnsJoinVO> listByZoneId(long dnsZoneId) {
SearchCriteria<NicDnsJoinVO> sc = activeDnsRecordsByZoneIdSearch.create();
sc.setParameters(ApiConstants.DNS_ZONE_ID, dnsZoneId);
return listBy(sc);
}
}

View File

@ -1194,7 +1194,7 @@ public class DnsProviderManagerImplTest {
NicDnsJoinVO existing =
mock(NicDnsJoinVO.class);
when(existing.getInstanceId()).thenReturn(99L);
when(nicDnsJoinDao.findActiveByDnsRecordAndZone("vm.example.com", ZONE_ID)).thenReturn(existing);
when(nicDnsJoinDao.findActiveByDnsRecordAndZone(ZONE_ID, "vm.example.com")).thenReturn(existing);
try (MockedStatic<com.cloud.event.ActionEventUtils> aeMock =
Mockito.mockStatic(com.cloud.event.ActionEventUtils.class)) {
@ -1209,7 +1209,7 @@ public class DnsProviderManagerImplTest {
@Test
public void testIsDnsCollisionReturnsFalseWhenNoExistingRecord() {
when(nicDnsJoinDao.findActiveByDnsRecordAndZone("vm.example.com", ZONE_ID)).thenReturn(null);
when(nicDnsJoinDao.findActiveByDnsRecordAndZone(ZONE_ID, "vm.example.com")).thenReturn(null);
boolean result = (boolean) ReflectionTestUtils.invokeMethod(
manager, "isDnsCollision", "vm.example.com", ZONE_ID, 42L);
assertFalse(result);
@ -1220,7 +1220,7 @@ public class DnsProviderManagerImplTest {
NicDnsJoinVO existing =
mock(NicDnsJoinVO.class);
when(existing.getInstanceId()).thenReturn(42L);
when(nicDnsJoinDao.findActiveByDnsRecordAndZone("vm.example.com", ZONE_ID)).thenReturn(existing);
when(nicDnsJoinDao.findActiveByDnsRecordAndZone(ZONE_ID, "vm.example.com")).thenReturn(existing);
boolean result = (boolean) ReflectionTestUtils.invokeMethod(
manager, "isDnsCollision", "vm.example.com", ZONE_ID, 42L);
assertFalse(result);
@ -1325,7 +1325,7 @@ public class DnsProviderManagerImplTest {
when(dnsServerDao.findById(SERVER_ID)).thenReturn(serverVO);
// no collision
when(nicDnsJoinDao.findActiveByDnsRecordAndZone(anyString(), eq(ZONE_ID))).thenReturn(null);
when(nicDnsJoinDao.findActiveByDnsRecordAndZone(eq(ZONE_ID), anyString())).thenReturn(null);
// sync: no IPs delete both
when(nicDnsJoinDao.listActiveByVmIdZoneAndDnsRecord(eq(51L), eq(ZONE_ID), anyString()))
.thenReturn(Collections.emptyList());
@ -1367,7 +1367,7 @@ public class DnsProviderManagerImplTest {
NicDnsJoinVO colliding =
mock(NicDnsJoinVO.class);
when(colliding.getInstanceId()).thenReturn(999L);
when(nicDnsJoinDao.findActiveByDnsRecordAndZone(anyString(), eq(ZONE_ID))).thenReturn(colliding);
when(nicDnsJoinDao.findActiveByDnsRecordAndZone(eq(ZONE_ID), anyString())).thenReturn(colliding);
try (MockedStatic<com.cloud.utils.db.Transaction> txMock =
Mockito.mockStatic(com.cloud.utils.db.Transaction.class);
@ -1438,7 +1438,7 @@ public class DnsProviderManagerImplTest {
when(dnsServerDao.findById(SERVER_ID)).thenReturn(serverVO);
// no collision for new record
when(nicDnsJoinDao.findActiveByDnsRecordAndZone(anyString(), eq(ZONE_ID))).thenReturn(null);
when(nicDnsJoinDao.findActiveByDnsRecordAndZone(eq(ZONE_ID), anyString())).thenReturn(null);
// sync always returns empty deleteRecord called
when(nicDnsJoinDao.listActiveByVmIdZoneAndDnsRecord(eq(62L), eq(ZONE_ID), anyString()))
.thenReturn(Collections.emptyList());
@ -1486,7 +1486,7 @@ public class DnsProviderManagerImplTest {
NicDnsJoinVO colliding =
mock(NicDnsJoinVO.class);
when(colliding.getInstanceId()).thenReturn(999L);
when(nicDnsJoinDao.findActiveByDnsRecordAndZone(anyString(), eq(ZONE_ID))).thenReturn(colliding);
when(nicDnsJoinDao.findActiveByDnsRecordAndZone(eq(ZONE_ID), anyString())).thenReturn(colliding);
when(nicDnsJoinDao.listActiveByVmIdZoneAndDnsRecord(eq(63L), eq(ZONE_ID), anyString()))
.thenReturn(Collections.emptyList());

View File

@ -59,6 +59,20 @@
v-focus="true" />
</a-form-item>
<a-form-item name="cleanup" ref="cleanup">
<template #label>
<tooltip-label :title="$t('label.cleanup')" :tooltip="apiParams.cleanup?.description" />
</template>
<a-switch v-model:checked="form.cleanup" @change="onCleanupChange" />
</a-form-item>
<a-form-item v-if="form.cleanup" name="unmanage" ref="unmanage">
<template #label>
<tooltip-label :title="$t('label.dns.unmanage.zone')" :tooltip="apiParams.unmanage?.description" />
</template>
<a-switch v-model:checked="form.unmanage" />
</a-form-item>
<div class="action-button">
<a-button @click="closeAction">
{{ $t('label.cancel') }}
@ -79,9 +93,13 @@
<script>
import { getAPI, postAPI } from '@/api'
import TooltipLabel from '@/components/widgets/TooltipLabel'
export default {
name: 'DeleteDnsServer',
components: {
TooltipLabel
},
props: {
resource: {
type: Object,
@ -91,9 +109,12 @@ export default {
data () {
return {
loading: false,
apiParams: {},
dnsZones: [],
form: {
name: ''
name: '',
cleanup: true,
unmanage: false
},
rules: {
name: [{ required: true, message: this.$t('message.error.required.input') }]
@ -103,9 +124,15 @@ export default {
}
},
created () {
this.apiParams = this.$getApiParams('deleteDnsServer') || {}
this.fetchDnsZones()
},
methods: {
onCleanupChange (checked) {
if (!checked) {
this.form.unmanage = false
}
},
async fetchDnsZones () {
this.loading = true
try {
@ -125,7 +152,12 @@ export default {
try {
const params = {
id: this.resource.id
id: this.resource.id,
cleanup: this.form.cleanup
}
if (this.form.cleanup) {
params.unmanage = this.form.unmanage
}
const response = await postAPI('deleteDnsServer', params)