2424from marvin .lib .common import (get_domain , get_zone , get_template )
2525from nose .plugins .attrib import attr
2626from marvin .codes import FAILED
27+ import os
2728import time
29+ from urllib .parse import urlsplit
30+
31+ SUPPORTED_PRIMARY_STORAGE_POOL_TYPES = ['networkfilesystem' , 'rbd' ]
2832
2933class 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