1818
1919from marvin .cloudstackAPI import listZones
2020from marvin .cloudstackTestCase import cloudstackTestCase
21- from marvin .lib .utils import (cleanup_resources )
2221from marvin .lib .base import (Account , Network , ServiceOffering , DiskOffering , VirtualMachine , BackupOffering ,
23- NetworkOffering , BackupRepository , Backup , Configurations , Volume , StoragePool )
22+ NetworkOffering , BackupRepository , Backup , Configurations , Volume , StoragePool , Template ,
23+ SSHKeyPair )
2424from marvin .lib .common import (get_domain , get_zone , get_template )
25+ from marvin .lib .utils import (cleanup_resources , random_gen )
2526from nose .plugins .attrib import attr
26- from marvin .codes import FAILED
2727import os
28+ import re
29+ import shlex
30+ import tempfile
2831import time
2932from urllib .parse import urlsplit
3033
@@ -48,9 +51,21 @@ def setUpClass(cls):
4851 if cls .hypervisor .lower () != 'kvm' :
4952 cls .skipTest (cls , reason = "Test can be run only on KVM hypervisor" )
5053
51- cls .template = get_template (cls .api_client , cls .zone .id , cls .services ["ostype" ])
52- if cls .template == FAILED :
53- assert False , "get_template() failed to return template with description %s" % cls .services ["ostype" ]
54+ # A cloud-init template (Ubuntu 24.04) rather than the legacy CentOS 5.5 one:
55+ # a modern kernel/udev enumerates hot-attached virtio disks and has mkfs.ext4,
56+ # sidestepping guest-side workarounds the older template needed, and cloud-init
57+ # grows the root filesystem to fill the disk on first boot on its own.
58+ cls .template = Template .register (
59+ cls .api_client ,
60+ cls .services ["test_templates_cloud_init" ][cls .hypervisor .lower ()],
61+ zoneid = cls .zone .id ,
62+ hypervisor = cls .hypervisor ,
63+ )
64+ cls .template .download (cls .api_client )
65+ # Not added to cls._cleanup here: cleanup_resources() deletes items in list
66+ # order, and a template can't be deleted while any VM still uses it -- it must
67+ # be appended after cls.vm (and hence the account that owns it) further down,
68+ # so the account/VM cleanup earlier in the list runs first.
5469 cls .services ["small" ]["zoneid" ] = cls .zone .id
5570 cls .services ["small" ]["template" ] = cls .template .id
5671
@@ -111,7 +126,24 @@ def setUpClass(cls):
111126
112127 cls .account = Account .create (cls .api_client , cls .services ["account" ], domainid = cls .domain .id )
113128
114- cls ._cleanup = [cls .account ]
129+ cls ._cleanup .append (cls .account )
130+
131+ # The cloud-init template's cloud-image hardening disables SSH password auth
132+ # outright (/etc/ssh/sshd_config.d/60-cloudimg-settings.conf sets
133+ # "PasswordAuthentication no", regardless of any password baked into the
134+ # image), so guest access here uses an SSH keypair and the stock cloud image
135+ # "ubuntu" user instead, matching the pattern used for cloud-init templates
136+ # elsewhere (e.g. test_network.py's TestSharedNetworkWithConfigDrive).
137+ cls .keypair = SSHKeyPair .create (cls .api_client , name = "nas-backup-%s" % random_gen (),
138+ account = cls .account .name , domainid = cls .account .domainid )
139+ cls .ssh_key_file = os .path .join (tempfile .gettempdir (), cls .keypair .name )
140+ with open (cls .ssh_key_file , "w" ) as key_file :
141+ key_file .write (cls .keypair .privatekey )
142+ os .chmod (cls .ssh_key_file , 0o400 )
143+ # createSSHKeyPair's response has no .delete() (it isn't wrapped as an
144+ # SSHKeyPair instance), so it isn't added to cls._cleanup; it is removed
145+ # along with everything else owned by cls.account when that gets deleted.
146+ cls .services ["small" ]["username" ] = "ubuntu"
115147
116148 # Create NAS backup repository and offering.
117149 cls .backup_repository = BackupRepository .add (cls .api_client , zoneid = cls .zone .id , name = "Nas" ,
@@ -136,7 +168,12 @@ def setUpClass(cls):
136168 cls ._cleanup .extend ([cls .offering , cls .diskoffering , cls .account ])
137169 cls .vm = VirtualMachine .create (cls .api_client , cls .services ["small" ], accountid = cls .account .name ,
138170 domainid = cls .account .domainid , serviceofferingid = cls .offering .id ,
139- diskofferingid = cls .diskoffering .id , mode = cls .services ["mode" ])
171+ diskofferingid = cls .diskoffering .id , mode = cls .services ["mode" ],
172+ keypair = cls .keypair .name )
173+ # Appended last so cleanup_resources() (which deletes cls._cleanup in list
174+ # order) removes cls.account -- and with it cls.vm, the template's only user
175+ # -- before it gets here.
176+ cls ._cleanup .append (cls .template )
140177
141178
142179 @classmethod
@@ -145,6 +182,10 @@ def tearDownClass(cls):
145182 # Cleanup resources used
146183 cleanup_resources (cls .api_client , cls ._cleanup )
147184
185+ ssh_key_file = getattr (cls , "ssh_key_file" , None )
186+ if ssh_key_file and os .path .exists (ssh_key_file ):
187+ os .remove (ssh_key_file )
188+
148189 if cls .backup_provider != "nas" :
149190 Configurations .update (cls .api_client , 'backup.framework.provider.plugin' , value = cls .backup_provider )
150191 if cls .incremental_backup_enabled == "false" :
@@ -170,8 +211,12 @@ def vm_backup_create_vm_from_backup_int(self, templateid=None, networkids=None):
170211
171212 # Create a file and take backup
172213 try :
173- ssh_client_vm = self .vm .get_ssh_client (reconnect = True )
174- ssh_client_vm .execute ("touch test_backup_and_recovery.txt" )
214+ ssh_client_vm = self .vm .get_ssh_client (reconnect = True , keyPairFileLocation = self .ssh_key_file )
215+ # sync is required here: without it, this file's data can still be sitting
216+ # in the guest's page cache (ext4 delayed allocation) rather than on the
217+ # RBD-backed disk when the backup is taken a few seconds later, and the
218+ # restored VM then comes up without it.
219+ ssh_client_vm .execute ("touch test_backup_and_recovery.txt; sync" )
175220 except Exception as err :
176221 self .fail ("SSH failed for Virtual machine: %s due to %s" % (self .vm .ipaddress , err ))
177222
@@ -206,6 +251,7 @@ def vm_backup_create_vm_from_backup_int(self, templateid=None, networkids=None):
206251 templateid = templateid
207252 )
208253 self .cleanup .append (new_vm )
254+ new_vm .username = "ubuntu"
209255
210256 # Verify the new VM was created successfully
211257 self .assertIsNotNone (new_vm , "Failed to create VM from backup" )
@@ -232,7 +278,7 @@ def vm_backup_create_vm_from_backup_int(self, templateid=None, networkids=None):
232278
233279 # Verify that the file is present in the Instance created from backup
234280 try :
235- ssh_client_new_vm = new_vm .get_ssh_client (reconnect = True )
281+ ssh_client_new_vm = new_vm .get_ssh_client (reconnect = True , keyPairFileLocation = self . ssh_key_file )
236282 result = ssh_client_new_vm .execute ("ls test_backup_and_recovery.txt" )
237283 self .assertEqual (result [0 ], "test_backup_and_recovery.txt" ,
238284 "Instance created from Backup should have the same file as the backup." )
@@ -375,14 +421,14 @@ def test_incremental_chain_cadence(self):
375421 original_full_every = self ._get_full_every ()
376422 self ._set_full_every (3 )
377423 try :
378- ssh_client_vm = self .vm .get_ssh_client (reconnect = True )
379- ssh_client_vm .execute ("touch /root/incremental_marker_1.txt" )
424+ ssh_client_vm = self .vm .get_ssh_client (reconnect = True , keyPairFileLocation = self . ssh_key_file )
425+ ssh_client_vm .execute (self . _sudo ( "touch /root/incremental_marker_1.txt" ) )
380426
381427 created = []
382428 for i in range (5 ):
383429 Backup .create (self .apiclient , self .vm .id , "inc_chain_%d" % i )
384430 # write a small change so each incremental has something to capture
385- ssh_client_vm .execute ("dd if=/dev/urandom of=/root/delta_%d bs=64k count=4 2>/dev/null" % i )
431+ ssh_client_vm .execute (self . _sudo ( "dd if=/dev/urandom of=/root/delta_%d bs=64k count=4 2>/dev/null" % i ) )
386432 time .sleep (2 )
387433 created = Backup .list (self .apiclient , self .vm .id )
388434
@@ -429,8 +475,8 @@ def test_incremental_after_vm_restart(self):
429475 self ._set_full_every (100 )
430476 backups = []
431477 try :
432- ssh_client_vm = self .vm .get_ssh_client (reconnect = True )
433- ssh_client_vm .execute ("echo restart-test-1 > /root/restart_marker_1.txt; sync" )
478+ ssh_client_vm = self .vm .get_ssh_client (reconnect = True , keyPairFileLocation = self . ssh_key_file )
479+ ssh_client_vm .execute (self . _sudo ( "echo restart-test-1 > /root/restart_marker_1.txt; sync" ) )
434480
435481 # 1) FULL anchor
436482 Backup .create (self .apiclient , self .vm .id , "restart_full" )
@@ -439,8 +485,8 @@ def test_incremental_after_vm_restart(self):
439485 # 2) Restart the VM — wipes libvirt's checkpoint registry (the bug trigger).
440486 self .vm .stop (self .apiclient )
441487 self .vm .start (self .apiclient )
442- ssh_client_vm = self .vm .get_ssh_client (reconnect = True )
443- ssh_client_vm .execute ("echo restart-test-2 > /root/restart_marker_2.txt; sync" )
488+ ssh_client_vm = self .vm .get_ssh_client (reconnect = True , keyPairFileLocation = self . ssh_key_file )
489+ ssh_client_vm .execute (self . _sudo ( "echo restart-test-2 > /root/restart_marker_2.txt; sync" ) )
444490
445491 # 3) INCREMENTAL after the restart — the previously-broken path.
446492 Backup .create (self .apiclient , self .vm .id , "restart_incr" )
@@ -469,12 +515,13 @@ def test_incremental_after_vm_restart(self):
469515 zoneid = self .zone .id
470516 )
471517 self .cleanup .append (new_vm )
518+ new_vm .username = "ubuntu"
472519 self .assertIsNotNone (new_vm , "Failed to create VM from the post-restart incremental backup" )
473520 self .assertEqual (new_vm .state , "Running" , "Restored VM should be Running" )
474521
475- ssh_new = new_vm .get_ssh_client (reconnect = True )
476- r1 = "" .join (ssh_new .execute ("cat /root/restart_marker_1.txt" ))
477- r2 = "" .join (ssh_new .execute ("cat /root/restart_marker_2.txt" ))
522+ ssh_new = new_vm .get_ssh_client (reconnect = True , keyPairFileLocation = self . ssh_key_file )
523+ r1 = "" .join (ssh_new .execute (self . _sudo ( "cat /root/restart_marker_1.txt" ) ))
524+ r2 = "" .join (ssh_new .execute (self . _sudo ( "cat /root/restart_marker_2.txt" ) ))
478525 self .assertIn ("restart-test-1" , r1 ,
479526 "Marker written before the restart is missing from the restore" )
480527 self .assertIn ("restart-test-2" , r2 ,
@@ -499,16 +546,16 @@ def test_restore_from_incremental(self):
499546 original_full_every = self ._get_full_every ()
500547 self ._set_full_every (5 )
501548 try :
502- ssh_client_vm = self .vm .get_ssh_client (reconnect = True )
503- ssh_client_vm .execute ("touch /root/marker_full.txt" )
549+ ssh_client_vm = self .vm .get_ssh_client (reconnect = True , keyPairFileLocation = self . ssh_key_file )
550+ ssh_client_vm .execute (self . _sudo ( "touch /root/marker_full.txt" ) )
504551 Backup .create (self .apiclient , self .vm .id , "rfi_full" )
505552 time .sleep (3 )
506553
507- ssh_client_vm .execute ("touch /root/marker_inc1.txt" )
554+ ssh_client_vm .execute (self . _sudo ( "touch /root/marker_inc1.txt" ) )
508555 Backup .create (self .apiclient , self .vm .id , "rfi_inc1" )
509556 time .sleep (3 )
510557
511- ssh_client_vm .execute ("touch /root/marker_inc2.txt" )
558+ ssh_client_vm .execute (self . _sudo ( "touch /root/marker_inc2.txt" ) )
512559 Backup .create (self .apiclient , self .vm .id , "rfi_inc2" )
513560 time .sleep (3 )
514561
@@ -524,10 +571,14 @@ def test_restore_from_incremental(self):
524571 accountname = self .account .name , domainid = self .account .domainid ,
525572 zoneid = self .zone .id )
526573 self .cleanup .append (new_vm )
574+ # createVMFromBackup wraps its response with an empty services dict, so
575+ # it always defaults to username "root" regardless of self.services --
576+ # override it to match the account this VM (and its SSH key) actually use.
577+ new_vm .username = "ubuntu"
527578
528- ssh_new = new_vm .get_ssh_client (reconnect = True )
579+ ssh_new = new_vm .get_ssh_client (reconnect = True , keyPairFileLocation = self . ssh_key_file )
529580 for marker in ("marker_full.txt" , "marker_inc1.txt" , "marker_inc2.txt" ):
530- result = ssh_new .execute ("ls /root/%s" % marker )
581+ result = ssh_new .execute (self . _sudo ( "ls /root/%s" % marker ) )
531582 self .assertIn (marker , result [0 ],
532583 "Restored VM should have %s (chain flattened correctly)" % marker )
533584
@@ -548,14 +599,14 @@ def test_delete_middle_incremental_repairs_chain(self):
548599 original_full_every = self ._get_full_every ()
549600 self ._set_full_every (5 )
550601 try :
551- ssh_client_vm = self .vm .get_ssh_client (reconnect = True )
552- ssh_client_vm .execute ("touch /root/dmi_full.txt" )
602+ ssh_client_vm = self .vm .get_ssh_client (reconnect = True , keyPairFileLocation = self . ssh_key_file )
603+ ssh_client_vm .execute (self . _sudo ( "touch /root/dmi_full.txt" ) )
553604 Backup .create (self .apiclient , self .vm .id , "dmi_full" )
554605 time .sleep (3 )
555- ssh_client_vm .execute ("touch /root/dmi_inc1.txt" )
606+ ssh_client_vm .execute (self . _sudo ( "touch /root/dmi_inc1.txt" ) )
556607 Backup .create (self .apiclient , self .vm .id , "dmi_inc1" )
557608 time .sleep (3 )
558- ssh_client_vm .execute ("touch /root/dmi_inc2.txt" )
609+ ssh_client_vm .execute (self . _sudo ( "touch /root/dmi_inc2.txt" ) )
559610 Backup .create (self .apiclient , self .vm .id , "dmi_inc2" )
560611 time .sleep (3 )
561612
@@ -575,11 +626,12 @@ def test_delete_middle_incremental_repairs_chain(self):
575626 accountname = self .account .name , domainid = self .account .domainid ,
576627 zoneid = self .zone .id )
577628 self .cleanup .append (new_vm )
578- ssh_new = new_vm .get_ssh_client (reconnect = True )
629+ new_vm .username = "ubuntu"
630+ ssh_new = new_vm .get_ssh_client (reconnect = True , keyPairFileLocation = self .ssh_key_file )
579631 # Both the FULL marker and (importantly) the deleted-INC1 marker should still
580632 # be present, because the rebase merged INC1's blocks into INC2.
581633 for marker in ("dmi_full.txt" , "dmi_inc1.txt" , "dmi_inc2.txt" ):
582- result = ssh_new .execute ("ls /root/%s" % marker )
634+ result = ssh_new .execute (self . _sudo ( "ls /root/%s" % marker ) )
583635 self .assertIn (marker , result [0 ],
584636 "After mid-incremental delete and rebase, %s should still be restorable" % marker )
585637
@@ -730,8 +782,8 @@ def test_restore_volume_and_attach_to_vm(self):
730782 target_vm = None
731783 self .backup_offering .assignOffering (self .apiclient , self .vm .id )
732784 try :
733- ssh_client_vm = self .vm .get_ssh_client (reconnect = True )
734- ssh_client_vm .execute ("echo restore-attach-marker > /root/restore_attach_marker.txt; sync" )
785+ ssh_client_vm = self .vm .get_ssh_client (reconnect = True , keyPairFileLocation = self . ssh_key_file )
786+ ssh_client_vm .execute (self . _sudo ( "echo restore-attach-marker > /root/restore_attach_marker.txt; sync" ) )
735787
736788 Backup .create (self .apiclient , self .vm .id , "restore_attach_backup" )
737789
@@ -756,9 +808,9 @@ def test_restore_volume_and_attach_to_vm(self):
756808 target_vm = VirtualMachine .create (
757809 self .apiclient , self .services ["small" ], accountid = self .account .name ,
758810 domainid = self .account .domainid , serviceofferingid = self .offering .id ,
759- mode = self .services ["mode" ]
811+ mode = self .services ["mode" ], keypair = self . keypair . name
760812 )
761- target_vm .stop (self .apiclient , forced = True )
813+ target_vm .stop (self .apiclient )
762814
763815 # Restore and attach the ROOT volume backup as an extra disk.
764816 Backup .restoreVolumeFromBackupAndAttachToVM (
@@ -790,3 +842,12 @@ def test_restore_volume_and_attach_to_vm(self):
790842 except Exception :
791843 pass
792844 self .backup_offering .removeOffering (self .apiclient , self .vm .id )
845+
846+ def _sudo (self , cmd ):
847+ """Wrap a shell command to run as root via sudo (the cloud-init "ubuntu" user
848+ this test SSHes in as has passwordless sudo) -- needed for anything touching
849+ /root or a raw block device, since the SSH session itself lands unprivileged.
850+ Runs the whole command through `sh -c` under sudo rather than just prefixing
851+ `sudo` onto the first word, so redirects and ';'-separated commands run with
852+ root's privileges too, not just whatever the first word happens to be."""
853+ return "sudo sh -c %s" % shlex .quote (cmd )
0 commit comments