Skip to content

Commit 0cdbf56

Browse files
committed
test: run the NAS backup smoke test on a cloud-init template
The suite registered the legacy CentOS 5.5 tiny template via get_template(). Its kernel/udev does not reliably enumerate hot-attached virtio disks and it ships no mkfs.ext4, so the chain tests needed guest-side workarounds to reach the data they back up and restore. Register the Ubuntu 24.04 cloud-init template instead, which enumerates hot-attached disks, carries the usual filesystem tools and grows its root filesystem on first boot by itself. The cloud image disables SSH password authentication outright (60-cloudimg-settings.conf), so guest access moves to an SSH keypair and the stock "ubuntu" user. That user lands unprivileged, so commands touching /root or a raw block device now go through a _sudo() helper which wraps the whole command in `sh -c` under sudo, keeping redirects and ';'-separated commands root-privileged rather than just the first word. Cleanup ordering changes with it: a template cannot be deleted while a VM still uses it, so it is appended to _cleanup after the account that owns the VM, since cleanup_resources() deletes in list order. The in-place multi-disk restore regression test that accompanied this change upstream is left out: it asserts the uuid-based volume mapping in LibvirtRestoreBackupCommandWrapper, which this branch does not carry.
1 parent 5fa0054 commit 0cdbf56

1 file changed

Lines changed: 98 additions & 37 deletions

File tree

‎test/integration/smoke/test_backup_recovery_nas.py‎

Lines changed: 98 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@
1818

1919
from marvin.cloudstackAPI import listZones
2020
from marvin.cloudstackTestCase import cloudstackTestCase
21-
from marvin.lib.utils import (cleanup_resources)
2221
from 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)
2424
from marvin.lib.common import (get_domain, get_zone, get_template)
25+
from marvin.lib.utils import (cleanup_resources, random_gen)
2526
from nose.plugins.attrib import attr
26-
from marvin.codes import FAILED
2727
import os
28+
import re
29+
import shlex
30+
import tempfile
2831
import time
2932
from 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

Comments
 (0)