Skip to content

Commit c6e0e9d

Browse files
fix(drivers): fix DOM child-removal and idempotent delete_machine
- document_set_tag/document_meta_set_tag used root.getElementsByTagName(tag).removeChild(node), but getElementsByTagName searches the whole subtree recursively; a match found deeper than a direct child makes removeChild raise xml.dom.NotFoundErr instead of removing anything (confirmed empirically). New _remove_direct_children() only removes actual direct children, shared by both methods. - delete_machine() called lookupByUUIDString unguarded, crashing with an uncaught libvirtError if the domain was already gone (e.g. a retry after an earlier delete destroyed/undefined it but failed later during volume cleanup). Now treats VIR_ERR_NO_DOMAIN as nothing-to-do and proceeds straight to volume cleanup. Tests use libvirt's built-in test:///default driver (real libvirt calls, no daemon/VMs needed) for delete_machine, and minidom directly for the child-removal regression. Also guards the test module's libvirt-bound imports with pytest.importorskip so it skips cleanly instead of failing collection where the libvirt bindings aren't installed.
1 parent ef31e66 commit c6e0e9d

2 files changed

Lines changed: 119 additions & 17 deletions

File tree

exordos_core/compute/pool/drivers/libvirt.py

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,19 @@ def add_element(
243243

244244
root.appendChild(element)
245245

246+
@classmethod
247+
def _remove_direct_children(cls, parent: minidom.Element, tag_name: str) -> None:
248+
"""Remove `parent`'s direct-child elements named `tag_name`.
249+
250+
getElementsByTagName searches the whole subtree recursively, not
251+
just direct children - removeChild() raises NotFoundErr if a
252+
match it returns isn't actually a direct child of the node
253+
you're calling it on.
254+
"""
255+
for node in list(parent.childNodes):
256+
if node.nodeType == node.ELEMENT_NODE and node.tagName == tag_name:
257+
parent.removeChild(node)
258+
246259
@classmethod
247260
def document_set_tag(
248261
cls,
@@ -255,14 +268,12 @@ def document_set_tag(
255268
) -> None:
256269
root = parent or docement.firstChild
257270
# Firstly we need to remove the old value
258-
for node in root.getElementsByTagName(tag_name):
259-
root.removeChild(node)
271+
cls._remove_direct_children(root, tag_name)
260272

261273
# Also we need to remove the old value from the meta
262274
if meta_tag is not None:
263275
meta_node = docement.getElementsByTagName(META_TAG)[0]
264-
for node in docement.getElementsByTagName(meta_tag):
265-
meta_node.removeChild(node)
276+
cls._remove_direct_children(meta_node, meta_tag)
266277

267278
# Add the new value
268279
cls.add_element(docement, meta_tag, parent=meta_node, text=text)
@@ -280,8 +291,7 @@ def document_meta_set_tag(
280291
) -> None:
281292
# Remove the old value from the meta
282293
meta_node = docement.getElementsByTagName(META_TAG)[0]
283-
for node in docement.getElementsByTagName(tag):
284-
meta_node.removeChild(node)
294+
cls._remove_direct_children(meta_node, tag)
285295

286296
# Add the new value
287297
cls.add_element(docement, tag, parent=meta_node, text=text, **kwargs)
@@ -1321,16 +1331,26 @@ def delete_machine(
13211331
13221332
:param machine: The machine to delete
13231333
"""
1324-
domain = self._client.lookupByUUIDString(str(machine.uuid))
1325-
1326-
# Remove the libvirt domain
1334+
# Idempotent: a retry after a previous delete already destroyed/
1335+
# undefined the domain but failed later (e.g. during volume
1336+
# cleanup below) must not crash here just because the domain is
1337+
# already gone.
13271338
try:
1328-
domain.destroy()
1329-
except libvirt.libvirtError:
1330-
LOG.debug("The domain is not in the running state")
1331-
# FIXME(akremenetsky): Actully we should undefine the
1332-
# domain before volume deletion
1333-
domain.undefine()
1339+
domain = self._client.lookupByUUIDString(str(machine.uuid))
1340+
except libvirt.libvirtError as e:
1341+
if e.get_error_code() != libvirt.VIR_ERR_NO_DOMAIN:
1342+
raise
1343+
domain = None
1344+
1345+
if domain is not None:
1346+
# Remove the libvirt domain
1347+
try:
1348+
domain.destroy()
1349+
except libvirt.libvirtError:
1350+
LOG.debug("The domain is not in the running state")
1351+
# FIXME(akremenetsky): Actully we should undefine the
1352+
# domain before volume deletion
1353+
domain.undefine()
13341354

13351355
if delete_volumes:
13361356
for volume in self.list_volumes(machine):

exordos_core/tests/unit/compute/pool/drivers/test_libvirt.py

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,32 @@
1414
# License for the specific language governing permissions and limitations
1515
# under the License.
1616

17+
import uuid as sys_uuid
18+
from xml.dom import minidom
1719
from xml.etree import ElementTree as ET
1820

19-
from exordos_core.compute.pool.drivers.libvirt import XMLLibvirtInstance
20-
from exordos_core.compute.pool.drivers.libvirt import domain_template
21+
import pytest
22+
23+
# The libvirt driver imports the `libvirt` python bindings at module level.
24+
# They aren't always installed, so skip this module instead of failing
25+
# collection when they're not available.
26+
pytest.importorskip("libvirt")
27+
28+
from exordos_core.compute.dm import models # noqa: E402
29+
from exordos_core.compute.pool.drivers.libvirt import LibvirtPoolDriver # noqa: E402
30+
from exordos_core.compute.pool.drivers.libvirt import XMLLibvirtInstance # noqa: E402
31+
from exordos_core.compute.pool.drivers.libvirt import domain_template # noqa: E402
32+
33+
34+
def _local_driver() -> LibvirtPoolDriver:
35+
# libvirt's built-in "test" driver simulates a hypervisor in-memory -
36+
# no real virtualization or daemon needed, so real libvirt calls
37+
# (lookupByUUIDString, etc.) can be exercised end-to-end.
38+
spec = models.LibvirtPoolDriverSpec(connection_uri="test:///default")
39+
pool = models.MachinePool(
40+
uuid=sys_uuid.uuid4(), name="test-pool", driver_spec=spec
41+
)
42+
return LibvirtPoolDriver(pool)
2143

2244

2345
def test_domain_console_logs_to_file():
@@ -35,3 +57,63 @@ def test_domain_console_logs_to_file():
3557
assert console.get("type") == "pty"
3658
assert log.get("file") == log_path
3759
assert log.get("append") == "on"
60+
61+
62+
class TestRemoveDirectChildren:
63+
def test_removes_only_direct_children_leaving_nested_matches_alone(self):
64+
# getElementsByTagName searches the whole subtree recursively -
65+
# a naive removeChild(node) on a match found deeper in the tree
66+
# (not a direct child of root) raises NotFoundErr.
67+
doc = minidom.parseString(
68+
"<root><a>direct</a><b><a>nested</a></b></root>"
69+
)
70+
root = doc.firstChild
71+
72+
XMLLibvirtInstance._remove_direct_children(root, "a")
73+
74+
assert root.getElementsByTagName("a") == doc.getElementsByTagName("b")[
75+
0
76+
].getElementsByTagName("a")
77+
assert len(doc.getElementsByTagName("a")) == 1
78+
assert doc.getElementsByTagName("a")[0].firstChild.data == "nested"
79+
80+
def test_leaves_other_tag_names_alone(self):
81+
doc = minidom.parseString("<root><a>1</a><c>2</c></root>")
82+
root = doc.firstChild
83+
84+
XMLLibvirtInstance._remove_direct_children(root, "a")
85+
86+
assert len(doc.getElementsByTagName("a")) == 0
87+
assert len(doc.getElementsByTagName("c")) == 1
88+
89+
def test_re_setting_a_tag_with_a_same_named_nested_element_does_not_crash(self):
90+
# Regression: domain_set_vcpu/domain_set_memory/etc. re-set their
91+
# tag on every call - this must not crash even if some unrelated
92+
# nested element happens to share the tag name.
93+
domain = XMLLibvirtInstance(domain_template)
94+
devices = ET.fromstring(domain.xml).find("devices")
95+
assert devices is not None # sanity: domain_template has one
96+
97+
domain.set_vcpu(2)
98+
domain.set_vcpu(4)
99+
domain.set_memory(1024)
100+
domain.set_memory(2048)
101+
102+
element = ET.fromstring(domain.xml)
103+
assert element.find(".//vcpu").text == "4"
104+
assert element.find(".//currentMemory").text == "2048"
105+
106+
107+
class TestDeleteMachine:
108+
def test_is_idempotent_when_the_domain_is_already_gone(self):
109+
driver = _local_driver()
110+
machine = models.Machine(
111+
uuid=sys_uuid.uuid4(),
112+
project_id=sys_uuid.uuid4(),
113+
name="never-existed",
114+
cores=1,
115+
ram=512,
116+
)
117+
118+
# Must not raise, even though no such domain was ever defined.
119+
driver.delete_machine(machine, delete_volumes=False)

0 commit comments

Comments
 (0)