Skip to content

Commit 6cc20e2

Browse files
fix(drivers): fix DOM child-removal and idempotent delete_machine (#528)
- 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.
1 parent 88e3036 commit 6cc20e2

2 files changed

Lines changed: 143 additions & 17 deletions

File tree

exordos_core/compute/pool/drivers/libvirt.py

Lines changed: 39 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,30 @@ 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+
LOG.debug(
1344+
"Domain for machine %s not found, assuming already deleted",
1345+
machine.uuid,
1346+
)
1347+
domain = None
1348+
1349+
if domain is not None:
1350+
# Remove the libvirt domain
1351+
try:
1352+
domain.destroy()
1353+
except libvirt.libvirtError:
1354+
LOG.debug("The domain is not in the running state")
1355+
# FIXME(akremenetsky): Actully we should undefine the
1356+
# domain before volume deletion
1357+
domain.undefine()
13341358

13351359
if delete_volumes:
13361360
for volume in self.list_volumes(machine):

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

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

17+
import uuid as sys_uuid
18+
from unittest import mock
19+
from xml.dom import minidom
1720
from xml.etree import ElementTree as ET
1821

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

2245

2346
def test_domain_console_logs_to_file():
@@ -35,3 +58,82 @@ def test_domain_console_logs_to_file():
3558
assert console.get("type") == "pty"
3659
assert log.get("file") == log_path
3760
assert log.get("append") == "on"
61+
62+
63+
class TestRemoveDirectChildren:
64+
def test_removes_only_direct_children_leaving_nested_matches_alone(self):
65+
# getElementsByTagName searches the whole subtree recursively -
66+
# a naive removeChild(node) on a match found deeper in the tree
67+
# (not a direct child of root) raises NotFoundErr.
68+
doc = minidom.parseString(
69+
"<root><a>direct</a><b><a>nested</a></b></root>"
70+
)
71+
root = doc.firstChild
72+
73+
XMLLibvirtInstance._remove_direct_children(root, "a")
74+
75+
assert root.getElementsByTagName("a") == doc.getElementsByTagName("b")[
76+
0
77+
].getElementsByTagName("a")
78+
assert len(doc.getElementsByTagName("a")) == 1
79+
assert doc.getElementsByTagName("a")[0].firstChild.data == "nested"
80+
81+
def test_leaves_other_tag_names_alone(self):
82+
doc = minidom.parseString("<root><a>1</a><c>2</c></root>")
83+
root = doc.firstChild
84+
85+
XMLLibvirtInstance._remove_direct_children(root, "a")
86+
87+
assert len(doc.getElementsByTagName("a")) == 0
88+
assert len(doc.getElementsByTagName("c")) == 1
89+
90+
def test_re_setting_a_tag_with_a_same_named_nested_element_does_not_crash(self):
91+
# Regression: domain_set_vcpu/domain_set_memory/etc. re-set their
92+
# tag on every call - this must not crash even if some unrelated
93+
# nested element happens to share the tag name.
94+
domain = XMLLibvirtInstance(domain_template)
95+
devices = ET.fromstring(domain.xml).find("devices")
96+
assert devices is not None # sanity: domain_template has one
97+
98+
domain.set_vcpu(2)
99+
domain.set_vcpu(4)
100+
domain.set_memory(1024)
101+
domain.set_memory(2048)
102+
103+
element = ET.fromstring(domain.xml)
104+
assert element.find(".//vcpu").text == "4"
105+
assert element.find(".//currentMemory").text == "2048"
106+
107+
108+
class TestDeleteMachine:
109+
def test_is_idempotent_when_the_domain_is_already_gone(self):
110+
driver = _local_driver()
111+
machine = models.Machine(
112+
uuid=sys_uuid.uuid4(),
113+
project_id=sys_uuid.uuid4(),
114+
name="never-existed",
115+
cores=1,
116+
ram=512,
117+
)
118+
119+
# Must not raise, even though no such domain was ever defined.
120+
driver.delete_machine(machine, delete_volumes=False)
121+
122+
def test_volume_cleanup_still_runs_when_the_domain_is_already_gone(self):
123+
driver = _local_driver()
124+
machine = models.Machine(
125+
uuid=sys_uuid.uuid4(),
126+
project_id=sys_uuid.uuid4(),
127+
name="never-existed",
128+
cores=1,
129+
ram=512,
130+
)
131+
132+
# The missing-domain path must fall through to volume cleanup,
133+
# not skip it.
134+
with mock.patch.object(
135+
driver, "list_volumes", return_value=[]
136+
) as mock_list_volumes:
137+
driver.delete_machine(machine, delete_volumes=True)
138+
139+
mock_list_volumes.assert_called_once_with(machine)

0 commit comments

Comments
 (0)