1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 11:46:49 +00:00
webvirtcloud/instances/tests/test_live_jobs.py
catborise dbc7e688ac feat(instances): a shut-off VM with a TPM migrates with its TPM state
A shut-off VM with TPM state, as every Windows 11 VM, was refused for
migration: the state is a file of the source host, and an offline move
would start it there with a new TPM. Such a VM now migrates as a shut-off
VM with disks to copy does: its UEFI variables are copied, it is started
paused, its CPUs never run, migrates live, which carries the TPM state,
and is shut off on the destination. A shut-off VM with a saved state is
refused before any of that, for every way it moves: started paused, it
would resume and then lose the saved memory.

The clone tab says that a clone gets a new, empty TPM, and the destroy
page that the TPM state goes with the VM.
2026-11-01 18:29:53 +03:00

325 lines
18 KiB
Python

"""Live checks for the instance jobs: the worker runs them against libvirt
and recovery reads the same outcome back. Runs only with TEST_LIBVIRT_HOST
set (see instances/tests/livetest.py). The migration needs a second host:
TEST_LIBVIRT_SSH_HOST (and _LOGIN), with TEST_LIBVIRT_MIGRATE_ADDRESS as its
migration address. The ISO upload runs on both hosts: TCP writes through a
libvirt stream, SSH through SFTP."""
import os
import subprocess
import time
import unittest
import libvirt
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TransactionTestCase
from django.urls import reverse
from vrtManager.connection import connection_manager, wvmConnect
from vrtManager.instance import wvmInstance
from appsettings.settings import get_settings
from computes.models import Compute
from instances.jobs import DeleteExternalSnapshot
from instances.models import Instance
from operations import jobs
from operations.models import Operation
from operations.tests import fakes
from operations.worker import Worker
from . import livetest
P = livetest.PREFIX
def wait_for(predicate, seconds):
end = time.monotonic() + seconds
while time.monotonic() < end:
if predicate():
return True
time.sleep(0.5)
return False
class LiveJobsTestCase(TransactionTestCase):
@classmethod
def setUpClass(cls):
if not livetest.enabled():
raise unittest.SkipTest("Set TEST_LIBVIRT_HOST to run the live libvirt tests")
cls.args = (
os.environ["TEST_LIBVIRT_HOST"],
os.environ.get("TEST_LIBVIRT_LOGIN", ""),
os.environ.get("TEST_LIBVIRT_PASSWORD", ""),
int(os.environ.get("TEST_LIBVIRT_TYPE", 4)),
)
cls.conn = connection_manager.get_connection(*cls.args)
livetest.cleanup(cls.conn)
livetest.ensure_pool(cls.conn)
cls.inventory = livetest.inventory(cls.conn)
super().setUpClass()
@classmethod
def tearDownClass(cls):
try:
livetest.remove_pool(cls.conn)
finally:
super().tearDownClass()
if livetest.inventory(cls.conn) != cls.inventory:
raise AssertionError("Live tests changed objects outside the test namespace")
def setUp(self):
get_settings()
host, login, password, conn_type = self.args
self.compute = Compute.objects.create(name=P + "compute", hostname=host, login=login, password=password,
type=conn_type)
self.admin = get_user_model().objects.create_superuser("job_live_admin", "a@example.com", "pw")
def tearDown(self):
livetest.cleanup(self.conn)
def vm(self, name, state="stopped"):
base = livetest.create_volume(self.conn, P + name)
dom = livetest.define_vm(self.conn, P + name, [base])
if state == "running":
dom.create()
return dom, wvmInstance(*self.args, None, uuid=dom.UUIDString())
def test_the_worker_deletes_a_snapshot_and_recovery_agrees(self):
dom, vm = self.vm("job-del", "running")
vm.create_external_snapshot("one")
instance = Instance.objects.create(compute=self.compute, name=dom.name(), uuid=dom.UUIDString())
op = jobs.submit("snapshot.delete", self.admin, {"instance": instance.pk, "name": "one"}, "key-live0001")
worker = Worker(concurrency=1, tick=0.1, heartbeat_every=1, progress_every=1, exit_on_lost_lease=False)
thread = fakes.start_worker(worker)
try:
self.assertTrue(wait_for(lambda: Operation.objects.get(pk=op.pk).state == Operation.SUCCEEDED, 180))
finally:
fakes.stop_worker(worker, thread)
self.assertEqual(dom.snapshotNum(0), 0)
Operation.objects.filter(pk=op.pk).update(state=Operation.RUNNING)
op.refresh_from_db()
self.assertEqual(DeleteExternalSnapshot().reconcile(op).state, Operation.SUCCEEDED)
def run_job(self, op):
worker = Worker(concurrency=1, tick=0.1, heartbeat_every=1, progress_every=1, exit_on_lost_lease=False)
thread = fakes.start_worker(worker)
try:
self.assertTrue(wait_for(lambda: Operation.objects.get(pk=op.pk).state in Operation.TERMINAL, 300))
finally:
fakes.stop_worker(worker, thread)
op.refresh_from_db()
self.assertEqual(op.state, Operation.SUCCEEDED, op.error)
return op
def test_snapshot_create_and_revert_run_as_jobs(self):
running, _vm = self.vm("smoke-ext", "running")
stopped, _vm = self.vm("smoke-int")
steps = ((running, "snapshot.create", {"name": "m", "external": True, "memory": True}),
(running, "snapshot.create", {"name": "d", "external": True}),
(running, "snapshot.revert", {"name": "m", "external": True}),
(stopped, "snapshot.create", {"name": "i"}),
(stopped, "snapshot.revert", {"name": "i"}))
for n, (dom, kind, params) in enumerate(steps):
instance = Instance.objects.get_or_create(compute=self.compute, name=dom.name(), uuid=dom.UUIDString())[0]
self.run_job(jobs.submit(kind, self.admin, {"instance": instance.pk, **params}, f"key-snap{n:04d}"))
self.assertEqual(running.snapshotCurrent(0).getName(), "m")
self.assertTrue(running.isActive()) # a memory snapshot resumes the VM
self.assertEqual(stopped.snapshotCurrent(0).getName(), "i")
def test_clone_create_and_volume_clone_run_as_jobs(self):
dom, _vm = self.vm("smoke-src")
source = Instance.objects.create(compute=self.compute, name=dom.name(), uuid=dom.UUIDString())
op = self.run_job(jobs.submit("instance.clone", self.admin, {
"instance": source.pk, "name": P + "smoke-clone", "disk-vda": P + "smoke-clone.qcow2",
"clone-net-mac-0": "52:54:00:00:31:01"}, "key-smoke001"))
self.assertEqual(self.conn.lookupByUUIDString(op.intent["uuid"]).name(), P + "smoke-clone")
self.run_job(jobs.submit("volume.clone", self.admin, {
"compute": self.compute.pk, "pool": livetest.POOL, "image": P + "smoke-src.qcow2",
"name": P + "smoke-copy"}, "key-smoke002"))
self.assertTrue(livetest.volume_exists(self.conn, f"{livetest.POOL_PATH}/{P}smoke-copy.qcow2"))
op = self.run_job(jobs.submit("instance.create", self.admin, {
"name": P + "smoke-new", "firmware": "BIOS", "vcpu": 1, "vcpu_mode": "host-model", "memory": 256,
"networks": "default", "storage": livetest.POOL, "template": P + "smoke-copy.qcow2", "images": "",
"hdd_size": 0, "cache_mode": "none", "meta_prealloc": False, "virtio": True, "qemu_ga": False,
"mac": "", "nwfilter": "", "net_model": "virtio", "add_cdrom": "None", "add_input": "default",
"video": "vga", "listener_addr": "127.0.0.1", "compute": self.compute.pk, "arch": "x86_64",
"machine": "q35"}, "key-smoke003"))
self.assertEqual(self.conn.lookupByUUIDString(op.intent["uuid"]).name(), P + "smoke-new")
def second_host(self):
if not os.environ.get("TEST_LIBVIRT_SSH_HOST"):
self.skipTest("Set TEST_LIBVIRT_SSH_HOST to a second host to run the live migration test")
dest = Compute.objects.create(name=P + "dest", hostname=os.environ["TEST_LIBVIRT_SSH_HOST"],
login=os.environ.get("TEST_LIBVIRT_SSH_LOGIN", "root"), password="", type=2,
migration_address=os.environ.get("TEST_LIBVIRT_MIGRATE_ADDRESS", ""))
dest_conn = connection_manager.get_connection(dest.hostname, dest.login, "", 2)
dest_inventory = livetest.inventory(dest_conn)
self.addCleanup(lambda: self.assertEqual(livetest.inventory(dest_conn), dest_inventory))
self.addCleanup(livetest.cleanup, dest_conn)
return dest, dest_conn
def test_a_migration_runs_as_a_job_there_and_back(self):
dest, dest_conn = self.second_host()
dom = livetest.define_vm(self.conn, P + "migrate", [])
dom.create()
uuid = dom.UUIDString()
instance = Instance.objects.create(compute=self.compute, name=dom.name(), uuid=uuid)
live = {"instance": instance.pk, "target": dest.pk, "live": True, "autoconverge": True}
self.run_job(jobs.submit("instance.migrate", self.admin, live, "key-mig00001"))
self.assertTrue(dest_conn.lookupByUUIDString(uuid).isActive())
dest_conn.lookupByUUIDString(uuid).destroy()
offline = {"instance": instance.pk, "target": self.compute.pk, "offline": True}
self.run_job(jobs.submit("instance.migrate", self.admin, offline, "key-mig00002"))
self.assertFalse(self.conn.lookupByUUIDString(uuid).isActive()) # defined on the source again
self.assertEqual(Instance.objects.get(uuid=uuid).compute, self.compute)
def test_a_disk_neither_host_shares_is_copied_there_and_back(self):
"""The test pool is a directory of each host's own: the disk is
copied into it, running there with its source file deleted on
request, shut off back (started paused for the copy) with it kept."""
dest, dest_conn = self.second_host()
self.addCleanup(livetest.remove_pool, dest_conn)
livetest.ensure_pool(dest_conn)
path = livetest.create_volume(self.conn, P + "copy")
dom = livetest.define_vm(self.conn, P + "copy", [path])
dom.create()
uuid = dom.UUIDString()
instance = Instance.objects.create(compute=self.compute, name=dom.name(), uuid=uuid)
pools = {"vda": livetest.POOL}
there = {"instance": instance.pk, "target": dest.pk, "live": True, "disk_pools": pools, "delete_source": True}
self.run_job(jobs.submit("instance.migrate", self.admin, there, "key-copy0001"))
self.assertTrue(dest_conn.lookupByUUIDString(uuid).isActive())
self.assertEqual((livetest.volume_exists(self.conn, path), livetest.volume_exists(dest_conn, path)),
(False, True))
dest_conn.lookupByUUIDString(uuid).destroy()
back = {"instance": instance.pk, "target": self.compute.pk, "offline": True, "disk_pools": pools}
self.run_job(jobs.submit("instance.migrate", self.admin, back, "key-copy0002"))
self.assertFalse(self.conn.lookupByUUIDString(uuid).isActive())
self.assertEqual((livetest.volume_exists(self.conn, path), livetest.volume_exists(dest_conn, path)),
(True, True))
def test_a_disk_moves_to_another_pool_and_back(self):
"""There while the VM runs (block copy and pivot), back shut off
(volume copy); each time the source file goes."""
other = self.conn.storagePoolDefineXML(
f"<pool type='dir'><name>{P}move</name><target><path>{livetest.POOL_PATH}-move</path></target></pool>")
other.create(libvirt.VIR_STORAGE_POOL_CREATE_WITH_BUILD)
def remove_other():
other.refresh(0)
for vol in other.listAllVolumes():
vol.delete(0)
other.destroy()
other.delete(0)
other.undefine()
self.addCleanup(remove_other)
path = livetest.create_volume(self.conn, P + "move")
dom = livetest.define_vm(self.conn, P + "move", [path])
dom.create()
instance = Instance.objects.create(compute=self.compute, name=dom.name(), uuid=dom.UUIDString())
moved = f"{livetest.POOL_PATH}-move/{P}move.qcow2"
def source(flags):
return dom.XMLDesc(flags).split("<source file='")[1].split("'")[0]
self.run_job(jobs.submit("disk.move", self.admin, {"instance": instance.pk, "dev": "vda", "pool": P + "move"},
"key-move0001"))
self.assertEqual((source(0), source(libvirt.VIR_DOMAIN_XML_INACTIVE)), (moved, moved))
self.assertFalse(livetest.volume_exists(self.conn, path)) # no host uses the source file: it went
dom.destroy()
self.run_job(jobs.submit("disk.move", self.admin, {"instance": instance.pk, "dev": "vda", "pool": livetest.POOL},
"key-move0002"))
self.assertEqual(source(libvirt.VIR_DOMAIN_XML_INACTIVE), path)
def test_a_shut_off_uefi_vm_migrates_with_its_nvram_there_and_back(self):
dest, _dest_conn = self.second_host()
hosts = {self.compute.pk: wvmConnect(*self.args), dest.pk: wvmConnect(dest.hostname, dest.login, "", 2)}
dom = livetest.define_vm(self.conn, P + "uefi", [], uefi=True)
dom.createWithFlags(libvirt.VIR_DOMAIN_START_PAUSED) # libvirt makes the NVRAM file
dom.destroy()
name = os.path.basename(wvmInstance(*self.args, None, uuid=dom.UUIDString()).get_nvram())
def nvram(compute):
host = hosts[compute.pk]
with host._nvram_pool() as pool:
vol = host._find_volume(pool, name)
if vol is None:
return None
stream, chunks = host.wvm.newStream(0), []
vol.download(stream, 0, 0, 0)
stream.recvAll(lambda _stream, data, out: out.append(data), chunks)
stream.finish()
return b"".join(chunks)
content = nvram(self.compute)
instance = Instance.objects.create(compute=self.compute, name=dom.name(), uuid=dom.UUIDString())
for n, (source, target) in enumerate(((self.compute, dest), (dest, self.compute))):
offline = {"instance": instance.pk, "target": target.pk, "offline": True}
self.run_job(jobs.submit("instance.migrate", self.admin, offline, f"key-uefi{n:04d}"))
self.assertEqual(nvram(target), content)
self.assertIsNone(nvram(source))
def test_a_shut_off_vm_with_tpm_state_migrates_with_it_there_and_back(self):
dest, dest_conn = self.second_host()
tpm = "<tpm model='tpm-crb'><backend type='emulator' version='2.0'/></tpm>"
# UEFI with a TPM, as a Windows 11 VM: its NVRAM file is copied, its TPM state migrated
dom = livetest.define_vm(self.conn, P + "tpm", [], uefi=True, extra_devices=tpm)
dom.createWithFlags(libvirt.VIR_DOMAIN_START_PAUSED) # swtpm makes the state
dom.destroy()
uuid = dom.UUIDString()
def state_on_dest():
"""The checksum of the TPM state file on the destination (an SSH host, as root)."""
path = f"/var/lib/libvirt/swtpm/{uuid}/tpm2/tpm2-00.permall"
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-l", dest.login, dest.hostname,
f"sha256sum {path} 2>/dev/null"], capture_output=True, text=True).stdout.split()[:1]
instance = Instance.objects.create(compute=self.compute, name=dom.name(), uuid=uuid)
sums = []
for n, target in enumerate((dest, self.compute, dest)):
offline = {"instance": instance.pk, "target": target.pk, "offline": True}
self.run_job(jobs.submit("instance.migrate", self.admin, offline, f"key-tpm{n:05d}"))
sums.append(state_on_dest())
self.assertFalse(dest_conn.lookupByUUIDString(uuid).isActive()) # shut off again after the move
# the same state went there, back and there again; none stays behind
self.assertTrue(sums[0])
self.assertEqual((sums[1], sums[2]), ([], sums[0]))
def upload(self, compute, key, chunks):
"""Post chunks of a two-chunk ISO upload to the storage view; returns the job."""
self.client.force_login(self.admin)
url = reverse("storage", args=[compute.pk, livetest.POOL])
name = P + "upload.iso"
for index in chunks:
answer = self.client.post(url, {
"iso_upload": "1", "file": SimpleUploadedFile(name, b"%d" % index * 4096), "file_name": name,
"chunk_index": str(index), "total_chunks": "2", "file_size": str(2 * 4096), "upload_id": key})
self.assertEqual(answer.status_code, 200, answer.content)
return Operation.objects.get(pk=answer.json()["job"])
def pool_volumes(self, conn):
pool = conn.storagePoolLookupByName(livetest.POOL)
pool.refresh(0)
return set(pool.listVolumes())
def test_an_iso_upload_runs_as_a_job(self):
hosts = [(self.compute, self.conn)]
if os.environ.get("TEST_LIBVIRT_SSH_HOST"):
ssh = Compute.objects.create(name=P + "ssh", hostname=os.environ["TEST_LIBVIRT_SSH_HOST"],
login=os.environ.get("TEST_LIBVIRT_SSH_LOGIN", "root"), password="", type=2)
ssh_conn = connection_manager.get_connection(ssh.hostname, ssh.login, "", 2)
ssh_inventory = livetest.inventory(ssh_conn)
self.addCleanup(lambda: self.assertEqual(livetest.inventory(ssh_conn), ssh_inventory))
self.addCleanup(livetest.remove_pool, ssh_conn)
livetest.ensure_pool(ssh_conn)
hosts.append((ssh, ssh_conn))
for n, (compute, conn) in enumerate(hosts):
with self.subTest(host=compute.name):
op = self.upload(compute, f"live-upload-{n}", (0, 1))
self.assertEqual(op.state, Operation.SUCCEEDED, op.error)
self.assertEqual(self.pool_volumes(conn), {P + "upload.iso"})
conn.storagePoolLookupByName(livetest.POOL).storageVolLookupByName(P + "upload.iso").delete(0)
op = self.upload(compute, f"live-cancel-{n}", (0,))
self.assertTrue(self.pool_volumes(conn)) # the volume, or the SSH part file
self.assertEqual(jobs.cancel(op.pk, self.admin).state, Operation.CANCELLED)
self.assertEqual(self.pool_volumes(conn), set())