Skip to content

Commit aa365d6

Browse files
committed
Improve VirtualBox descriptor parsing
1 parent b009e23 commit aa365d6

4 files changed

Lines changed: 576 additions & 42 deletions

File tree

Lines changed: 211 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,226 @@
11
from __future__ import annotations
22

3+
from datetime import datetime
4+
from functools import cached_property
35
from typing import TYPE_CHECKING, TextIO
6+
from uuid import UUID
47

58
from defusedxml import ElementTree
69

710
if TYPE_CHECKING:
8-
from collections.abc import Iterator
911
from xml.etree.ElementTree import Element
1012

13+
NS = "{http://www.virtualbox.org/}"
14+
1115

1216
class VBox:
13-
VBOX_XML_NAMESPACE = "{http://www.virtualbox.org/}"
17+
"""VirtualBox XML descriptor parser.
18+
19+
Args:
20+
fh: A file-like object of the VirtualBox XML descriptor.
21+
"""
1422

1523
def __init__(self, fh: TextIO):
1624
self._xml: Element = ElementTree.fromstring(fh.read())
25+
if self._xml.tag != f"{NS}VirtualBox":
26+
raise ValueError("Invalid VirtualBox XML descriptor: root element is not VirtualBox")
27+
28+
if (machine := self._xml.find(f"./{NS}Machine")) is None:
29+
raise ValueError("Invalid VirtualBox XML descriptor: no Machine element found")
30+
31+
if machine.find(f"./{NS}Hardware") is None:
32+
raise ValueError("Invalid VirtualBox XML descriptor: no Hardware element found")
33+
34+
self.machine = Machine(self, machine)
35+
36+
def __repr__(self) -> str:
37+
return f"<VBox uuid={self.uuid} name={self.name}>"
38+
39+
@property
40+
def uuid(self) -> UUID | None:
41+
"""The VM UUID."""
42+
return self.machine.uuid
43+
44+
@property
45+
def name(self) -> str | None:
46+
"""The VM name."""
47+
return self.machine.name
48+
49+
@property
50+
def media(self) -> dict[UUID, HardDisk]:
51+
"""The media (disks) registry."""
52+
return self.machine.media
53+
54+
@property
55+
def hardware(self) -> Hardware:
56+
"""The current machine hardware state."""
57+
return self.machine.hardware
58+
59+
@property
60+
def snapshots(self) -> dict[UUID, Snapshot]:
61+
"""All snapshots."""
62+
return self.machine.snapshots
63+
64+
65+
class Machine:
66+
def __init__(self, vbox: VBox, element: Element):
67+
self.vbox = vbox
68+
self.element = element
69+
70+
def __repr__(self) -> str:
71+
return f"<Machine uuid={self.uuid} name={self.name}>"
72+
73+
@property
74+
def uuid(self) -> UUID:
75+
"""The machine UUID."""
76+
return UUID(self.element.get("uuid").strip("{}"))
77+
78+
@property
79+
def name(self) -> str:
80+
"""The machine name."""
81+
return self.element.get("name")
82+
83+
@property
84+
def current_snapshot(self) -> UUID | None:
85+
"""The current snapshot UUID."""
86+
if (value := self.element.get("currentSnapshot")) is not None:
87+
return UUID(value.strip("{}"))
88+
return None
89+
90+
@cached_property
91+
def media(self) -> dict[UUID, HardDisk]:
92+
"""The media (disks) registry."""
93+
result = {}
94+
95+
stack = [(None, element) for element in self.element.find(f"./{NS}MediaRegistry/{NS}HardDisks")]
96+
while stack:
97+
parent, element = stack.pop()
98+
hdd = HardDisk(self, element, parent)
99+
result[hdd.uuid] = hdd
100+
101+
stack.extend([(hdd, child) for child in element.findall(f"./{NS}HardDisk")])
102+
103+
return result
104+
105+
@cached_property
106+
def hardware(self) -> Hardware:
107+
"""The machine hardware state."""
108+
return Hardware(self.vbox, self.element.find(f"./{NS}Hardware"))
109+
110+
@cached_property
111+
def snapshots(self) -> dict[UUID, Snapshot]:
112+
"""All snapshots."""
113+
result = {}
114+
115+
if (element := self.element.find(f"./{NS}Snapshot")) is None:
116+
return result
117+
118+
stack = [(None, element)]
119+
while stack:
120+
parent, element = stack.pop()
121+
snapshot = Snapshot(self.vbox, element, parent)
122+
result[snapshot.uuid] = snapshot
123+
124+
if (snapshots := element.find(f"./{NS}Snapshots")) is not None:
125+
stack.extend([(snapshot, child) for child in list(snapshots)])
126+
127+
return result
128+
129+
@property
130+
def parent(self) -> Snapshot | None:
131+
if (uuid := self.current_snapshot) is not None:
132+
return self.vbox.snapshots[uuid]
133+
return None
134+
135+
136+
class HardDisk:
137+
def __init__(self, vbox: VBox, element: Element, parent: HardDisk | None = None):
138+
self.vbox = vbox
139+
self.element = element
140+
self.parent = parent
141+
142+
def __repr__(self) -> str:
143+
return f"<HardDisk uuid={self.uuid} location={self.location}>"
144+
145+
@property
146+
def uuid(self) -> UUID:
147+
"""The disk UUID."""
148+
return UUID(self.element.get("uuid").strip("{}"))
149+
150+
@property
151+
def location(self) -> str:
152+
"""The disk location."""
153+
return self.element.get("location")
154+
155+
@property
156+
def type(self) -> str | None:
157+
"""The disk type."""
158+
return self.element.get("type")
159+
160+
@property
161+
def format(self) -> str:
162+
"""The disk format."""
163+
return self.element.get("format")
164+
165+
@cached_property
166+
def properties(self) -> dict[str, str]:
167+
"""The disk properties."""
168+
return {prop.get("name"): prop.get("value") for prop in self.element.findall(f"./{NS}Property")}
169+
170+
@property
171+
def is_encrypted(self) -> bool:
172+
"""Whether the disk is encrypted."""
173+
disk = self
174+
while disk is not None:
175+
if "CRYPT/KeyId" in disk.properties or "CRYPT/KeyStore" in disk.properties:
176+
return True
177+
disk = disk.parent
178+
179+
return False
180+
181+
182+
class Snapshot:
183+
def __init__(self, vbox: VBox, element: Element, parent: Snapshot | Machine | None = None):
184+
self.vbox = vbox
185+
self.element = element
186+
self.parent = parent
187+
188+
def __repr__(self) -> str:
189+
return f"<Snapshot uuid={self.uuid} name={self.name}>"
190+
191+
@property
192+
def uuid(self) -> UUID:
193+
"""The snapshot UUID."""
194+
return UUID(self.element.get("uuid").strip("{}"))
195+
196+
@property
197+
def name(self) -> str:
198+
"""The snapshot name."""
199+
return self.element.get("name")
200+
201+
@property
202+
def ts(self) -> datetime:
203+
"""The snapshot timestamp."""
204+
return datetime.strptime(self.element.get("timeStamp"), "%Y-%m-%dT%H:%M:%S%z")
205+
206+
@cached_property
207+
def hardware(self) -> Hardware:
208+
"""The snapshot hardware state."""
209+
return Hardware(self.vbox, self.element.find(f"./{NS}Hardware"))
210+
211+
212+
class Hardware:
213+
def __init__(self, vbox: VBox, element: Element):
214+
self.vbox = vbox
215+
self.element = element
216+
217+
def __repr__(self) -> str:
218+
return f"<Hardware disks={len(self.disks)}>"
17219

18-
def disks(self) -> Iterator[str]:
19-
for hdd_elem in self._xml.findall(f".//{self.VBOX_XML_NAMESPACE}HardDisk[@location][@type='Normal']"):
20-
# Allow format specifier to be case-insensitive (i.e. VDI, vdi)
21-
if (format := hdd_elem.get("format")) and format.lower() == "vdi":
22-
yield hdd_elem.attrib["location"]
220+
@property
221+
def disks(self) -> list[HardDisk]:
222+
"""All attached hard disks."""
223+
images = self.element.findall(
224+
f"./{NS}StorageControllers/{NS}StorageController/{NS}AttachedDevice[@type='HardDisk']/{NS}Image"
225+
)
226+
return [self.vbox.media[UUID(image.get("uuid").strip("{}"))] for image in images]

0 commit comments

Comments
 (0)