Skip to content

Commit fff8e56

Browse files
committed
test: support Ceph/RBD primary storage in NAS backup smoke test
test_backup_recovery_nas.py only allowed NFS primary storage, since it reused the primary storage pool's own path as the NAS backup repository address, and always required incremental-backup semantics that only qcow2/NFS storage can provide. Neither holds on Ceph/RBD. - setUpClass now accepts RBD alongside NFS as the primary storage pool type, picking a pool that's actually Up rather than list()[0] -- environments that added Ceph/RBD after the zone's original NFS primary storage keep that old pool around in Disabled state, and it still sorts first, silently exercising its path as if it were the storage VMs actually deploy on. - The NAS backup repository's NFS export address is resolved independently of the primary storage: when the primary pool isn't NFS, reuse the nfs test data entry (services[nfs][url]) -- the same temporary NFS mount point test_primary_storage.py uses for its temporary NFS primary storage pool, and something every marvin environment already has configured. An explicit nas_backup_repository_address test data entry or NAS_BACKUP_REPO_ADDRESS environment variable, if set, takes precedence. - The external offering imported in setUpClass is matched to the repository just created by externalid (== the repository's own id for the nas provider) rather than blindly taking index 0 -- a stray repository left over from an earlier interrupted run, whose backups didn't get cleaned up so its own teardown couldn't remove it either, sorts alongside the new one with no guarantee of which comes first. - Incremental NAS backups require QEMU dirty bitmaps / libvirt checkpoints, which only exist on file-based qcow2 storage (NASBackupProvider.allVolumesOnCheckpointCapableStorage). The six incremental-chain tests now skip on RBD/Ceph, where the provider always falls back to full-only backups server-side, rather than failing on assertions that storage type can never satisfy. - Added test_restore_volume_and_attach_to_vm, which exercises restoreVolumeFromBackupAndAttachToVM end-to-end (restoring a backed-up ROOT and DATADISK volume onto a second, stopped Instance) -- the API that drives the restore-and-attach code fixed by the previous commit (#14007). The target Instance is stopped with forced=True: a graceful ACPI stop was observed to time out (~2 minutes) before falling back to a hard destroy anyway, and once forced to a hard destroy the domain drops out of libvirt entirely, so the periodic ping-based PowerState sync the restore call depends on falls back to a much slower heuristic well past any reasonable wait. A forced stop destroys the domain immediately and deterministically.
1 parent 3e26f3a commit fff8e56

1 file changed

Lines changed: 155 additions & 11 deletions

File tree

test/integration/smoke/test_backup_recovery_nas.py

Lines changed: 155 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@
2424
from marvin.lib.common import (get_domain, get_zone, get_template)
2525
from nose.plugins.attrib import attr
2626
from marvin.codes import FAILED
27+
import os
2728
import time
29+
from urllib.parse import urlsplit
30+
31+
SUPPORTED_PRIMARY_STORAGE_POOL_TYPES = ['networkfilesystem', 'rbd']
2832

2933
class TestNASBackupAndRecovery(cloudstackTestCase):
3034

@@ -39,19 +43,56 @@ def setUpClass(cls):
3943
cls.services["mode"] = cls.zone.networktype
4044
cls.hypervisor = cls.testClient.getHypervisorInfo()
4145
cls.domain = get_domain(cls.api_client)
46+
cls._cleanup = []
47+
48+
if cls.hypervisor.lower() != 'kvm':
49+
cls.skipTest(cls, reason="Test can be run only on KVM hypervisor")
50+
4251
cls.template = get_template(cls.api_client, cls.zone.id, cls.services["ostype"])
4352
if cls.template == FAILED:
4453
assert False, "get_template() failed to return template with description %s" % cls.services["ostype"]
4554
cls.services["small"]["zoneid"] = cls.zone.id
4655
cls.services["small"]["template"] = cls.template.id
47-
cls._cleanup = []
4856

49-
if cls.hypervisor.lower() != 'kvm':
50-
cls.skipTest(cls, reason="Test can be run only on KVM hypervisor")
51-
52-
cls.storage_pool = StoragePool.list(cls.api_client)[0]
53-
if cls.storage_pool.type.lower() != 'networkfilesystem':
54-
cls.skipTest(cls, reason="Test can be run only if the primary storage is of type NFS. The pool type is %s " % cls.storage_pool.type)
57+
# Pick a pool that's actually usable, not just list()[0] -- environments that
58+
# added Ceph/RBD storage after the zone's original NFS primary storage keep the
59+
# old NFS pool around in Disabled state, and it still sorts first. Falling back
60+
# to index 0 there silently exercises the disabled NFS pool's path as if it were
61+
# the primary storage in use, rather than the RBD pool VMs actually deploy on.
62+
storage_pools = StoragePool.list(cls.api_client)
63+
usable_pools = [p for p in storage_pools if getattr(p, 'state', 'Up') == 'Up']
64+
cls.storage_pool = usable_pools[0] if usable_pools else storage_pools[0]
65+
if cls.storage_pool.type.lower() not in SUPPORTED_PRIMARY_STORAGE_POOL_TYPES:
66+
cls.skipTest(cls, reason="Test can be run only if the primary storage is of type NFS or RBD (Ceph)")
67+
68+
# The NAS backup repository needs an NFS export to mount. When the primary
69+
# storage is itself NFS, its own path can double as that export (the
70+
# historical behaviour). When the primary storage is Ceph/RBD, the primary
71+
# storage location can't be reused as a NAS export, so fall back to the
72+
# "nfs" test data entry -- the same NFS mount point test_primary_storage.py
73+
# uses to create its temporary NFS primary storage pool, and something every
74+
# marvin environment already has configured (services["nfs"]["url"], e.g.
75+
# "nfs://nfs/export/automation/1/testprimary"). An explicit
76+
# "nas_backup_repository_address" test data entry or NAS_BACKUP_REPO_ADDRESS
77+
# environment variable, if set, takes precedence over both.
78+
if cls.storage_pool.type.lower() == 'networkfilesystem':
79+
default_nas_repository_address = cls.storage_pool.ipaddress + ":" + cls.storage_pool.path
80+
else:
81+
nfs_test_data = cls.services.get("nfs")
82+
if nfs_test_data and nfs_test_data.get("url"):
83+
nfs_url = urlsplit(nfs_test_data["url"])
84+
default_nas_repository_address = "%s:%s" % (nfs_url.hostname, nfs_url.path)
85+
else:
86+
default_nas_repository_address = None
87+
cls.nas_repository_address = cls.services.get("nas_backup_repository_address") \
88+
or os.environ.get("NAS_BACKUP_REPO_ADDRESS") \
89+
or default_nas_repository_address
90+
if not cls.nas_repository_address:
91+
cls.skipTest(cls, reason="No NAS backup repository export configured. Set "
92+
"'nas_backup_repository_address' in the test data, the "
93+
"NAS_BACKUP_REPO_ADDRESS environment variable, or the standard "
94+
"'nfs' test data entry, when the primary storage is not NFS "
95+
"(e.g. Ceph/RBD)")
5596

5697
# Check backup configuration values, set them to enable the nas provider
5798
backup_enabled_cfg = Configurations.list(cls.api_client, name='backup.framework.enabled')
@@ -72,14 +113,22 @@ def setUpClass(cls):
72113

73114
cls._cleanup = [cls.account]
74115

75-
# Create NAS backup repository and offering. Use the same directory as the storage pool
116+
# Create NAS backup repository and offering.
76117
cls.backup_repository = BackupRepository.add(cls.api_client, zoneid=cls.zone.id, name="Nas",
77-
address=cls.storage_pool.ipaddress + ":" + cls.storage_pool.path,
118+
address=cls.nas_repository_address,
78119
provider="nas", type="nfs",)
79120
cls._cleanup.append(cls.backup_repository)
121+
# Match the external offering to the repository just created above by externalid
122+
# (== the repository's own id for the nas provider) rather than blindly taking
123+
# index 0 -- a stray repository left over from an earlier interrupted run (e.g.
124+
# one whose backups didn't get cleaned up, so its own teardown couldn't remove
125+
# it either) sorts alongside the new one, and index 0 has no guarantee of being
126+
# the one this run owns.
80127
cls.provider_offerings = BackupOffering.listExternal(cls.api_client, cls.zone.id)
81-
cls.backup_offering = BackupOffering.importExisting(cls.api_client, cls.zone.id, cls.provider_offerings[0].externalid,
82-
cls.provider_offerings[0].name, cls.provider_offerings[0].description)
128+
matching_offerings = [o for o in cls.provider_offerings if o.externalid == cls.backup_repository.id]
129+
provider_offering = matching_offerings[0] if matching_offerings else cls.provider_offerings[0]
130+
cls.backup_offering = BackupOffering.importExisting(cls.api_client, cls.zone.id, provider_offering.externalid,
131+
provider_offering.name, provider_offering.description)
83132
cls._cleanup.append(cls.backup_offering)
84133

85134
cls.offering = ServiceOffering.create(cls.api_client,cls.services["service_offerings"]["small"])
@@ -303,12 +352,26 @@ def _backup_type(self, backup):
303352
# Backup objects expose `type`; for chained backups it's "INCREMENTAL", else "FULL".
304353
return getattr(backup, 'type', 'FULL') or 'FULL'
305354

355+
def _require_incremental_capable_storage(self):
356+
"""
357+
Incremental NAS backups rely on QEMU dirty bitmaps / libvirt checkpoints, which
358+
only exist on file-based qcow2 storage -- see
359+
NASBackupProvider.allVolumesOnCheckpointCapableStorage(), which forces every VM
360+
on RBD/Ceph (or Linstor) onto the legacy full-only path server-side. On such
361+
storage every backup comes back FULL regardless of cadence, so these chain/type
362+
assertions can't pass (and some would pass vacuously without exercising the
363+
chain logic at all). Skip rather than fail when running against RBD.
364+
"""
365+
if self.storage_pool.type.lower() == 'rbd':
366+
self.skipTest("Incremental backups are not supported on RBD/Ceph primary Storage")
367+
306368
@attr(tags=["advanced", "backup"], required_hardware="true")
307369
def test_incremental_chain_cadence(self):
308370
"""
309371
With nas.backup.full.every=3, the sequence of backups should be
310372
FULL, INCREMENTAL, INCREMENTAL, FULL, INCREMENTAL, ...
311373
"""
374+
self._require_incremental_capable_storage()
312375
self.backup_offering.assignOffering(self.apiclient, self.vm.id)
313376
original_full_every = self._get_full_every()
314377
self._set_full_every(3)
@@ -358,6 +421,7 @@ def test_incremental_after_vm_restart(self):
358421
FULL + marker1 -> stop/start the VM (wipes the checkpoint registry)
359422
-> INCREMENTAL + marker2 -> restore the tip -> both markers present.
360423
"""
424+
self._require_incremental_capable_storage()
361425
self.backup_offering.assignOffering(self.apiclient, self.vm.id)
362426
original_full_every = self._get_full_every()
363427
# High cadence so the post-restart backup is INCREMENTAL, not a periodic FULL.
@@ -430,6 +494,7 @@ def test_restore_from_incremental(self):
430494
Take FULL + 2 INCREMENTAL backups, each with a marker file. Restore from the
431495
latest incremental and verify all three markers are present (chain flatten).
432496
"""
497+
self._require_incremental_capable_storage()
433498
self.backup_offering.assignOffering(self.apiclient, self.vm.id)
434499
original_full_every = self._get_full_every()
435500
self._set_full_every(5)
@@ -479,6 +544,7 @@ def test_delete_middle_incremental_repairs_chain(self):
479544
The chain repair should rebase INC2 onto FULL, and the final restore
480545
should still produce a working VM with all expected blocks.
481546
"""
547+
self._require_incremental_capable_storage()
482548
self.backup_offering.assignOffering(self.apiclient, self.vm.id)
483549
original_full_every = self._get_full_every()
484550
self._set_full_every(5)
@@ -531,6 +597,7 @@ def test_delete_full_with_children_is_deferred(self):
531597
FULL is hidden from the backup list while its child survives, and it is
532598
physically swept once the last descendant is deleted.
533599
"""
600+
self._require_incremental_capable_storage()
534601
self.backup_offering.assignOffering(self.apiclient, self.vm.id)
535602
original_full_every = self._get_full_every()
536603
self._set_full_every(5)
@@ -568,6 +635,7 @@ def test_stopped_vm_falls_back_to_full(self):
568635
would call for an incremental, the agent must fall back to a full and start a
569636
new chain. The incrementalFallback flag should be reflected in backup.type=FULL.
570637
"""
638+
self._require_incremental_capable_storage()
571639
self.backup_offering.assignOffering(self.apiclient, self.vm.id)
572640
original_full_every = self._get_full_every()
573641
self._set_full_every(2) # next backup after the first should be incremental
@@ -594,3 +662,79 @@ def test_stopped_vm_falls_back_to_full(self):
594662
finally:
595663
self._set_full_every(original_full_every)
596664
self.backup_offering.removeOffering(self.apiclient, self.vm.id)
665+
666+
# ------------------------------------------------------------------
667+
# Restore-volume-and-attach regression (PR apache/cloudstack#14007)
668+
# ------------------------------------------------------------------
669+
# This test exercises the fixed path end to end via restoreVolumeFromBackupAndAttachToVM,
670+
# on whichever primary storage this environment is running with (NFS or Ceph/RBD).
671+
672+
@attr(tags=["advanced", "backup"], required_hardware="true")
673+
def test_restore_volume_and_attach_to_vm(self):
674+
"""
675+
Test restoring the ROOT and DATADISK volumes of a backup and attaching them
676+
to a different Instance (restoreVolumeFromBackupAndAttachToVM).
677+
"""
678+
target_vm = None
679+
self.backup_offering.assignOffering(self.apiclient, self.vm.id)
680+
try:
681+
ssh_client_vm = self.vm.get_ssh_client(reconnect=True)
682+
ssh_client_vm.execute("echo restore-attach-marker > /root/restore_attach_marker.txt; sync")
683+
684+
Backup.create(self.apiclient, self.vm.id, "restore_attach_backup")
685+
686+
backups = Backup.list(self.apiclient, self.vm.id)
687+
self.assertEqual(len(backups), 1, "There should exist only one backup for the VM")
688+
backup = backups[0]
689+
690+
volumes = Volume.list(self.apiclient, virtualmachineid=self.vm.id, listall=True)
691+
self.assertTrue(isinstance(volumes, list), "List volumes should return a valid list")
692+
root_disk_id = None
693+
data_disk_id = None
694+
for volume in volumes:
695+
if volume.type == 'ROOT':
696+
root_disk_id = volume.id
697+
elif volume.type == 'DATADISK':
698+
data_disk_id = volume.id
699+
self.assertIsNotNone(root_disk_id, "The backed up VM should have a ROOT volume")
700+
701+
# Target Instance that will receive the restored volumes. The nas provider
702+
# (unlike KBOSS) requires the target Instance to be stopped before a volume
703+
# can be restored and attached to it.
704+
target_vm = VirtualMachine.create(
705+
self.apiclient, self.services["small"], accountid=self.account.name,
706+
domainid=self.account.domainid, serviceofferingid=self.offering.id,
707+
mode=self.services["mode"]
708+
)
709+
target_vm.stop(self.apiclient, forced=True)
710+
711+
# Restore and attach the ROOT volume backup as an extra disk.
712+
Backup.restoreVolumeFromBackupAndAttachToVM(
713+
self.apiclient, backupid=backup.id, volumeid=root_disk_id, virtualmachineid=target_vm.id
714+
)
715+
target_volumes = Volume.list(self.apiclient, virtualmachineid=target_vm.id, listall=True)
716+
self.assertTrue(isinstance(target_volumes, list), "List volumes should return a valid list")
717+
self.assertEqual(2, len(target_volumes),
718+
"Target Instance should have its own ROOT volume plus the restored volume")
719+
720+
if data_disk_id:
721+
# Restore and attach the DATADISK volume backup as well.
722+
Backup.restoreVolumeFromBackupAndAttachToVM(
723+
self.apiclient, backupid=backup.id, volumeid=data_disk_id, virtualmachineid=target_vm.id
724+
)
725+
target_volumes = Volume.list(self.apiclient, virtualmachineid=target_vm.id, listall=True)
726+
self.assertEqual(3, len(target_volumes),
727+
"Target Instance should have 3 volumes after restoring both the ROOT and DATADISK backups")
728+
729+
# Start the target Instance to verify the restored disk(s) are actually
730+
# usable and libvirt accepted the attach-device/attach-disk calls.
731+
target_vm.start(self.apiclient)
732+
733+
Backup.delete(self.apiclient, backup.id)
734+
finally:
735+
if target_vm is not None:
736+
try:
737+
target_vm.delete(self.apiclient)
738+
except Exception:
739+
pass
740+
self.backup_offering.removeOffering(self.apiclient, self.vm.id)

0 commit comments

Comments
 (0)