1#!/usr/bin/env python3
2import argparse
3import json
4import os
5from pathlib import Path
6import re
7import subprocess
8import sys
9import time
10import uuid
11import xml.etree.ElementTree as ET
12
13
14def main():
15 parser = argparse.ArgumentParser()
16 parser.add_argument("socket")
17 parser.add_argument("--images", type=Path, required=True)
18 parser.add_argument("--output", type=Path)
19 args = parser.parse_args()
20 client = Path(__file__).with_name("dashboard-host-vm-test.py")
21
22 def call(operation, payload=None):
23 request = {"operation": "vm." + operation}
24 if payload is not None:
25 request["payload"] = payload
26 result = subprocess.run([sys.executable, str(client), args.socket, "--client"],
27 input=json.dumps(request).encode() + b"\n", capture_output=True)
28 assert result.returncode == 0, result.stderr.decode()
29 return json.loads(result.stdout)
30
31 name = "boundary-" + uuid.uuid4().hex
32 existing = set(subprocess.check_output(["virsh", "list", "--all", "--name"], text=True).split())
33 spec = {"name": name, "description": "isolated VM fixture", "image": "blank", "vcpus": 1,
34 "memory": 2**29, "disk": 2**30, "autostart": False, "start": False}
35 node = call("node")["value"]
36 for field, value in [("vcpus", node["cpus"] + 1), ("memory", node["memory"] + 1), ("disk", 2**64),
37 ("vcpus", True), ("image", "../escape.iso"), ("name", "../escape"), ("xml", "<domain/>")]:
38 assert call("create", {**spec, field: value})["status"] == 400, (field, value)
39 assert call("act", {"name": name, "action": ["start"]})["status"] == 400
40 assert call("update", {"name": name})["status"] == 400
41 assert call("images")["value"][0]["volume"] == "blank"
42 images = args.images
43 created_images = not images.exists()
44 images.mkdir(parents=True, exist_ok=True)
45 source = images / (name + ".qcow2")
46 backing = images / (name + "-backed.qcow2")
47 external = images / (name + "-external.qcow2")
48 unsupported = images / (name + "-unsupported.img")
49 installer = images / (name + ".iso")
50 secret = Path("/run/" + name + "-outside-image-directory")
51 secret.write_bytes(b"synthetic private fixture\n" * 4096)
52 secret.chmod(0o600)
53 inactive = re.search(r"^Active:\s+no", subprocess.check_output(["virsh", "net-info", "default"], text=True), re.MULTILINE)
54 try:
55 subprocess.run(["qemu-img", "create", "-f", "qcow2", "-F", "raw", "-b", str(secret), str(backing)], check=True, capture_output=True)
56 rejected = call("create", {**spec, "image": backing.name})
57 assert rejected["status"] == 400 and "standalone" in rejected["error"], rejected
58 assert not (Path("/srv/vm") / name).exists()
59 subprocess.run(["qemu-img", "create", "-f", "qcow2", "-o", "data_file=" + str(secret) + ",data_file_raw=on", str(external), "1048576"], check=True, capture_output=True)
60 assert call("create", {**spec, "image": external.name})["status"] == 400
61 subprocess.run(["qemu-img", "create", "-f", "vmdk", str(unsupported), "1048576"], check=True, capture_output=True)
62 assert call("create", {**spec, "image": unsupported.name})["status"] == 400
63 subprocess.run(["qemu-img", "create", "-f", "qcow2", str(source), str(2**30)], check=True, capture_output=True)
64 assert call("create", {**spec, "image": source.name}) == {"value": None}
65 directory = Path("/srv/vm") / name
66 assert directory.is_dir() and (directory / "disk.qcow2").is_file()
67 assert not (directory / "source-image").exists()
68 description = "--config <literal description>"
69 assert call("update", {"name": name, "description": description, "autostart": True}) == {"value": None}
70 vm = next(vm for vm in call("domains")["value"] if vm["name"] == name)
71 assert vm["description"] == description and vm["autostart"], vm
72 assert call("act", {"name": name, "action": "start"}) == {"value": None}
73 assert name in call("stats")["value"]
74 subprocess.run(["virsh", "suspend", name], check=True, capture_output=True)
75 assert call("act", {"name": name, "action": "resume"}) == {"value": None}
76 assert call("act", {"name": name, "action": "destroy"}) == {"value": None}
77 assert call("remove", {"name": name, "disks": True}) == {"value": None}
78 assert not directory.exists()
79 installer.write_bytes(b"synthetic installer fixture\n" * 1024)
80 interrupted = """import json, sys, time
81sys.path.insert(0, sys.argv[1])
82import vms
83spec = json.load(sys.stdin)
84if sys.argv[2] == 'copy':
85 def copy(incoming, outgoing):
86 outgoing.write(incoming.read(1024))
87 outgoing.flush()
88 time.sleep(60)
89 vms.shutil.copyfileobj = copy
90else:
91 original = vms.virsh
92 def virsh(*args):
93 result = original(*args)
94 if args[0] == 'define':
95 raise OSError('fixture connection lost after definition')
96 return result
97 vms.virsh = virsh
98vms.validate('create', spec)
99try:
100 vms.create(spec)
101except OSError as error:
102 print(str(error), file=sys.stderr)
103 sys.exit(1)
104raise AssertionError('interrupted creation reported success')
105"""
106 for phase in ["copy", "define"]:
107 started = time.monotonic()
108 attempt = subprocess.run([sys.executable, "-c", interrupted, str(Path(__file__).parent), phase],
109 input=json.dumps({**spec, "image": installer.name if phase == "copy" else "blank"}),
110 capture_output=True, text=True, timeout=60,
111 env={**os.environ, "STUDIO_VM_IMAGES_ROOT": str(images)})
112 elapsed = time.monotonic() - started
113 assert attempt.returncode == 1, attempt.stderr
114 if phase == "copy":
115 assert "preparation timed out" in attempt.stderr and elapsed < 60, (elapsed, attempt.stderr)
116 assert not directory.exists() and name not in {vm["name"] for vm in call("domains")["value"]}
117 copy_timeout_seconds = round(elapsed, 2)
118 else:
119 assert "connection lost" in attempt.stderr and (directory / "disk.qcow2").is_file(), attempt.stderr
120 assert name in {vm["name"] for vm in call("domains")["value"]}
121 assert call("remove", {"name": name, "disks": True}) == {"value": None}
122 assert not directory.exists()
123 assert installer.read_bytes() == b"synthetic installer fixture\n" * 1024
124 assert call("create", {**spec, "image": installer.name}) == {"value": None}
125 xml = ET.fromstring(subprocess.check_output(["virsh", "dumpxml", name], text=True))
126 cdrom = xml.find("./devices/disk[@device='cdrom']")
127 assert cdrom.find("driver").get("type") == "raw"
128 assert cdrom.find("source").get("file") == str(directory / "installer.iso")
129 assert call("remove", {"name": name, "disks": True}) == {"value": None}
130 directory.symlink_to(secret.parent, target_is_directory=True)
131 try:
132 assert call("remove", {"name": name, "disks": True})["status"] == 400
133 assert secret.exists()
134 finally:
135 directory.unlink()
136 assert set(subprocess.check_output(["virsh", "list", "--all", "--name"], text=True).split()) == existing
137 result = {"vm_read_queries": "passed", "vm_resource_and_field_bounds": "passed",
138 "vm_create_start_resume_stop_remove": "passed", "literal_description": "passed",
139 "standalone_image_conversion": "passed", "external_backing_file_rejection": "passed",
140 "external_data_file_and_format_rejection": "passed", "pinned_raw_installer": "passed",
141 "symlink_disk_directory_rejection": "passed", "interrupted_copy_cleanup": "passed",
142 "copy_timeout_seconds": copy_timeout_seconds, "uncertain_definition_preserves_disks": "passed",
143 "existing_domains_preserved": "passed"}
144 if args.output:
145 args.output.write_text(json.dumps(result, indent=2) + "\n")
146 print(json.dumps(result))
147 finally:
148 subprocess.run(["virsh", "destroy", name], capture_output=True)
149 subprocess.run(["virsh", "undefine", name], capture_output=True)
150 directory = Path("/srv/vm") / name
151 if directory.is_dir() and not directory.is_symlink():
152 for file in directory.iterdir():
153 file.unlink()
154 directory.rmdir()
155 for file in [source, backing, external, unsupported, installer, secret]:
156 file.unlink(missing_ok=True)
157 if created_images and not any(images.iterdir()):
158 images.rmdir()
159 if inactive and not subprocess.check_output(["virsh", "list", "--name"], text=True).strip():
160 subprocess.run(["virsh", "net-destroy", "default"], check=True, capture_output=True)
161
162
163if __name__ == "__main__":
164 main()