diff options
-rw-r--r-- | doc/client/tools/augeas.txt | 72 | ||||
-rw-r--r-- | doc/server/plugins/generators/rules.txt | 14 | ||||
-rw-r--r-- | schemas/augeas.xsd | 220 | ||||
-rw-r--r-- | schemas/types.xsd | 13 | ||||
-rw-r--r-- | src/lib/Bcfg2/Client/Tools/POSIX/Augeas.py | 211 | ||||
-rw-r--r-- | testsuite/Testsrc/Testlib/TestClient/TestTools/TestPOSIX/TestAugeas.py | 233 | ||||
-rw-r--r-- | testsuite/common.py | 53 |
7 files changed, 798 insertions, 18 deletions
diff --git a/doc/client/tools/augeas.txt b/doc/client/tools/augeas.txt new file mode 100644 index 000000000..94ed9066f --- /dev/null +++ b/doc/client/tools/augeas.txt @@ -0,0 +1,72 @@ +.. -*- mode: rst -*- + +.. _client-tools-augeas: + +======== + Augeas +======== + +The Augeas tool provides a way to use `Augeas +<http://www.augeas.net>`_ to edit files that may not be completely +managed. + +In the simplest case, you simply tell Augeas which path to edit, and +give it a sequence of commands: + +.. code-block:: xml + + <Path type="augeas" name="/etc/hosts" owner="root" group="root" + mode="0644"> + <Set path="01/ipaddr" value="192.168.0.1"/> + <Set path="01/canonical" value="pigiron.example.com"/> + <Set path="01/alias[1]" value="pigiron"/> + <Set path="01/alias[2]" value="piggy"/> + </Path> + +The commands are run in document order. There's no need to do an +explicit ``save`` at the end. + +Each of these commands will only be run if the path does not already +have the given setting. That is, the ip address for the first host +record will only be set to ``192.168.0.1`` if it's not set to that +value already. Its canonical name will only be set to +``pigiron.example.com`` if it's not that already; and so on. + +The Augeas paths are all relative to ``/files/etc/hosts``. + +The Augeas tool understands a subset of ``augtool`` commands. Valid +tags are: ``Remove``, ``Move``, ``Set``, ``Clear``, ``SetMulti``, and +``Insert``. Refer to the official Augeas docs or the `Schema`_ below +for details on the commands. + +Editing files outside the default load path +=========================================== + +If you're using Augeas to edit files outside of its default load path, +you must manually specify the lens. For instance: + +.. code-block:: xml + + <Path type="augeas" name="/opt/jenkins/home/config.xml" lens="Xml" + owner="jenkins" group="jenkins" mode="0640"> + <Set path="hudson/systemMessage/#text" + value="This is a Jenkins server."/> + </Path> + +Note that there's no need to manually modify the load path by setting +``/augeas/load/<lens>/incl``, nor do you have to call ``load`` +explicitly. + +Schema +====== + +.. xml:group:: augeasCommands + + +Performance +=========== + +The Augeas tool is quite slow to initialize. For each ``<Path +type="augeas" ... >`` entry you have, it creates a new Augeas object +internally, which can take several seconds. It's thus important to +use this tool sparingly. diff --git a/doc/server/plugins/generators/rules.txt b/doc/server/plugins/generators/rules.txt index 9ba70238d..a21dd217f 100644 --- a/doc/server/plugins/generators/rules.txt +++ b/doc/server/plugins/generators/rules.txt @@ -136,6 +136,20 @@ Attributes common to all Path tags: :onlyattrs: name,type +augeas +^^^^^^ + +Run `Augeas <http://www.augeas.net>`_ commands. See +:ref:`client-tools-augeas` for more details. + +.. xml:type:: PathType + :nochildren: + :noattributegroups: + :nodoc: + :notext: + :onlyattrs: owner,group,mode,secontext,lens + :requiredattrs: owner,group,mode + device ^^^^^^ diff --git a/schemas/augeas.xsd b/schemas/augeas.xsd new file mode 100644 index 000000000..0ede106f3 --- /dev/null +++ b/schemas/augeas.xsd @@ -0,0 +1,220 @@ +<?xml version="1.0" encoding="utf-8"?> +<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" + xmlns:py="http://genshi.edgewall.org/" xml:lang="en"> + + <xsd:annotation> + <xsd:documentation> + Augeas commands + </xsd:documentation> + </xsd:annotation> + + <xsd:import namespace="http://genshi.edgewall.org/" + schemaLocation="genshi.xsd"/> + + <xsd:complexType name="AugeasRemoveCommand"> + <xsd:annotation> + <xsd:documentation> + Implementation of the Augeas ``rm`` command. + </xsd:documentation> + </xsd:annotation> + <xsd:attribute type="xsd:string" name="path" use="required"> + <xsd:annotation> + <xsd:documentation> + Delete nodes (and all children) matching the given Augeas + path expression. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attributeGroup ref="py:genshiAttrs"/> + </xsd:complexType> + + <xsd:complexType name="AugeasMoveCommand"> + <xsd:annotation> + <xsd:documentation> + Implementation of the Augeas ``mv`` command. + </xsd:documentation> + </xsd:annotation> + <xsd:attribute type="xsd:string" name="source" use="required"> + <xsd:annotation> + <xsd:documentation> + Move the node matching this path expression. ``source`` + must match exactly one node. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attribute type="xsd:string" name="destination" use="required"> + <xsd:annotation> + <xsd:documentation> + Move the node to this location. ``destination`` must match + either zero or one nodes. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attributeGroup ref="py:genshiAttrs"/> + </xsd:complexType> + + <xsd:complexType name="AugeasSetCommand"> + <xsd:annotation> + <xsd:documentation> + Implementation of the Augeas ``set`` command. + </xsd:documentation> + </xsd:annotation> + <xsd:attribute type="xsd:string" name="path" use="required"> + <xsd:annotation> + <xsd:documentation> + Path to set the value for. If the path does not exist, it + and all of its ancestors will be created. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attribute type="xsd:string" name="value" use="required"> + <xsd:annotation> + <xsd:documentation> + Value to set. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attributeGroup ref="py:genshiAttrs"/> + </xsd:complexType> + + <xsd:complexType name="AugeasClearCommand"> + <xsd:annotation> + <xsd:documentation> + Implementation of the Augeas ``clear`` command. + </xsd:documentation> + </xsd:annotation> + <xsd:attribute type="xsd:string" name="path" use="required"> + <xsd:annotation> + <xsd:documentation> + Path whose value will be set to ``NULL``. If the path does + not exist, it and all of its ancestors will be created. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attributeGroup ref="py:genshiAttrs"/> + </xsd:complexType> + + <xsd:complexType name="AugeasSetMultiCommand"> + <xsd:annotation> + <xsd:documentation> + Set multiple node values at once. + </xsd:documentation> + </xsd:annotation> + <xsd:attribute type="xsd:string" name="base" use="required"> + <xsd:annotation> + <xsd:documentation> + The base path. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attribute type="xsd:string" name="sub" use="required"> + <xsd:annotation> + <xsd:documentation> + ``sub`` will be used as an expression relative to each node + that matches the :xml:attribute:`AugeasSetMultiCommand:base` + expression. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attribute type="xsd:string" name="value" use="required"> + <xsd:annotation> + <xsd:documentation> + The value to set on all nodes that match + :xml:attribute:`AugeasSetMultiCommand:sub` relative to each + node matching :xml:attribute:`AugeasSetMultiCommand:base`. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attributeGroup ref="py:genshiAttrs"/> + </xsd:complexType> + + <xsd:simpleType name="AugeasWhenEnum"> + <xsd:restriction base="xsd:string"> + <xsd:enumeration value="before"/> + <xsd:enumeration value="after"/> + </xsd:restriction> + </xsd:simpleType> + + <xsd:complexType name="AugeasInsertCommand"> + <xsd:annotation> + <xsd:documentation> + Implementation of the Augeas ``ins`` command. + </xsd:documentation> + </xsd:annotation> + <xsd:attribute type="xsd:string" name="path" use="required"> + <xsd:annotation> + <xsd:documentation> + The path to a node that will be the sibling of the new node. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attribute type="xsd:string" name="label" use="required"> + <xsd:annotation> + <xsd:documentation> + The label of the new node to be created. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attribute type="AugeasWhenEnum" name="where" default="before"> + <xsd:annotation> + <xsd:documentation> + Where to create the node: ``before`` or ``after`` the + sibling given in :xml:attribute:`AugeasInsertCommand:path`. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attributeGroup ref="py:genshiAttrs"/> + </xsd:complexType> + + <xsd:group name="augeasCommands"> + <xsd:annotation> + <xsd:documentation> + All available Augeas commands. + </xsd:documentation> + </xsd:annotation> + <xsd:choice> + <xsd:element name="Remove" type="AugeasRemoveCommand"> + <xsd:annotation> + <xsd:documentation> + Implementation of the Augeas ``rm`` command. + </xsd:documentation> + </xsd:annotation> + </xsd:element> + <xsd:element name="Move" type="AugeasMoveCommand"> + <xsd:annotation> + <xsd:documentation> + Implementation of the Augeas ``mv`` command. + </xsd:documentation> + </xsd:annotation> + </xsd:element> + <xsd:element name="Set" type="AugeasSetCommand"> + <xsd:annotation> + <xsd:documentation> + Implementation of the Augeas ``set`` command. + </xsd:documentation> + </xsd:annotation> + </xsd:element> + <xsd:element name="Clear" type="AugeasClearCommand"> + <xsd:annotation> + <xsd:documentation> + Implementation of the Augeas ``clear`` command. + </xsd:documentation> + </xsd:annotation> + </xsd:element> + <xsd:element name="SetMulti" type="AugeasSetMultiCommand"> + <xsd:annotation> + <xsd:documentation> + Set multiple node values at once. + </xsd:documentation> + </xsd:annotation> + </xsd:element> + <xsd:element name="Insert" type="AugeasInsertCommand"> + <xsd:annotation> + <xsd:documentation> + Implementation of the Augeas ``ins`` command. + </xsd:documentation> + </xsd:annotation> + </xsd:element> + </xsd:choice> + </xsd:group> +</xsd:schema> diff --git a/schemas/types.xsd b/schemas/types.xsd index 03d2e9ddc..ee4f13ea9 100644 --- a/schemas/types.xsd +++ b/schemas/types.xsd @@ -9,6 +9,7 @@ </xsd:annotation> <xsd:include schemaLocation="selinux.xsd"/> + <xsd:include schemaLocation="augeas.xsd"/> <xsd:import namespace="http://genshi.edgewall.org/" schemaLocation="genshi.xsd"/> @@ -41,6 +42,7 @@ <xsd:simpleType name='PathTypeEnum'> <xsd:restriction base='xsd:string'> + <xsd:enumeration value='augeas' /> <xsd:enumeration value='device' /> <xsd:enumeration value='directory' /> <xsd:enumeration value='file' /> @@ -133,7 +135,7 @@ <xsd:annotation> <xsd:documentation> If the action is always run, or is only run when a bundle - has been modified. + has been modified. </xsd:documentation> </xsd:annotation> </xsd:attribute> @@ -267,6 +269,7 @@ <xsd:choice minOccurs='0' maxOccurs='unbounded'> <xsd:element name='ACL' type='ACLType'/> + <xsd:group name="augeasCommands"/> </xsd:choice> <xsd:attribute type="PathTypeEnum" name="type"> <xsd:annotation> @@ -394,6 +397,14 @@ </xsd:documentation> </xsd:annotation> </xsd:attribute> + <xsd:attribute type="xsd:token" name="lens"> + <xsd:annotation> + <xsd:documentation> + The Augeas lens to use when editing files in a non-standard + (according to Augeas) location. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> <xsd:attributeGroup ref="py:genshiAttrs"/> </xsd:complexType> diff --git a/src/lib/Bcfg2/Client/Tools/POSIX/Augeas.py b/src/lib/Bcfg2/Client/Tools/POSIX/Augeas.py new file mode 100644 index 000000000..cda9a1e3b --- /dev/null +++ b/src/lib/Bcfg2/Client/Tools/POSIX/Augeas.py @@ -0,0 +1,211 @@ +""" Augeas driver """ + +import sys +import augeas +import Bcfg2.Client.XML +from Bcfg2.Client.Tools.POSIX.base import POSIXTool + + +class AugeasCommand(object): + def __init__(self, command, augeas, logger): + self.augeas = augeas + self.command = command + self.entry = self.command.getparent() + self.logger = logger + + def get_path(self, attr="path"): + return "/files/%s/%s" % (self.entry.get("name").strip("/"), + self.command.get(attr).lstrip("/")) + + def _exists(self, path): + return len(self.augeas.match(path)) > 1 + + def _verify_exists(self, path=None): + if path is None: + path = self.get_path() + self.logger.debug("Augeas: Verifying that '%s' exists" % path) + return self._exists(path) + + def _verify_not_exists(self, path=None): + if path is None: + path = self.get_path() + self.logger.debug("Augeas: Verifying that '%s' does not exist" % path) + return not self._exists(path) + + def _verify_set(self, expected, path=None): + if path is None: + path = self.get_path() + self.logger.debug("Augeas: Verifying '%s' == '%s'" % (path, expected)) + actual = self.augeas.get(path) + if actual == expected: + return True + else: + self.logger.debug("Augeas: '%s' failed verification: '%s' != '%s'" + % (path, actual, expected)) + return False + + def __str__(self): + return Bcfg2.Client.XML.tostring(self.command) + + +class Remove(AugeasCommand): + def verify(self): + return self._verify_not_exists() + + def install(self): + self.logger.debug("Augeas: Removing %s" % self.get_path()) + return self.augeas.remove(self.get_path()) + + +class Move(AugeasCommand): + def __init__(self, command, augeas, logger): + AugeasCommand.__init__(self, command, augeas, logger) + self.source = self.get_path("source") + self.dest = self.get_path("destination") + + def verify(self): + return (self._verify_not_exists(self.source), + self._verify_exists(self.dest)) + + def install(self): + self.logger.debug("Augeas: Moving %s to %s" % (self.source, self.dest)) + return self.augeas.move(self.source, self.dest) + + +class Set(AugeasCommand): + def __init__(self, command, augeas, logger): + AugeasCommand.__init__(self, command, augeas, logger) + self.value = self.command.get("value") + + def verify(self): + return self._verify_set(self.value) + + def install(self): + self.logger.debug("Augeas: Setting %s to %s" % (self.get_path(), + self.value)) + return self.augeas.set(self.get_path(), self.value) + + +class Clear(Set): + def __init__(self, command, augeas, logger): + Set.__init__(self, command, augeas, logger) + self.value = None + + +class SetMulti(AugeasCommand): + def __init__(self, command, augeas, logger): + AugeasCommand.__init__(self, command, augeas, logger) + self.sub = self.command.get("sub") + self.value = self.command.get("value") + self.base = self.get_path("base") + + def verify(self): + return all(self._verify_set(self.value, + path="%s/%s" % (path, self.sub)) + for path in self.augeas.match(self.base)) + + def install(self): + return self.augeas.setm(self.base, self.sub, self.value) + + +class Insert(AugeasCommand): + def __init__(self, command, augeas, logger): + AugeasCommand.__init__(self, command, augeas, logger) + self.label = self.command.get("label") + self.where = self.command.get("where", "before") + self.before = self.where == "before" + + def verify(self): + return self._verify_exists("%s/../%s" % (self.get_path(), self.label)) + + def install(self): + self.logger.debug("Augeas: Inserting new %s %s %s" % + (self.label, self.where, self.get_path())) + return self.augeas.insert(self.get_path(), self.label, self.before) + + +class POSIXAugeas(POSIXTool): + """ Handle <Path type='augeas'...> entries. See + :ref:`client-tools-augeas`. """ + + __handles__ = [('Path', 'augeas')] + __req__ = {'Path': ['type', 'name', 'setting', 'value']} + + def __init__(self, logger, setup, config): + POSIXTool.__init__(self, logger, setup, config) + self._augeas = dict() + + def get_augeas(self, entry): + if entry.get("name") not in self._augeas: + aug = augeas.augeas() + if entry.get("lens"): + self.logger.debug("Augeas: Adding %s to include path for %s" % + (entry.get("name"), entry.get("lens"))) + incl = "/augeas/load/%s/incl" % entry.get("lens") + ilen = len(aug.match(incl)) + if ilen == 0: + self.logger.error("Augeas: Lens %s does not exist" % + entry.get("lens")) + else: + aug.set("%s[%s]" % (incl, ilen + 1), entry.get("name")) + aug.load() + self._augeas[entry.get("name")] = aug + return self._augeas[entry.get("name")] + + def fully_specified(self, entry): + return entry.text is not None + + def get_commands(self, entry, unverified=False): + rv = [] + for cmd in entry.iterchildren(): + if cmd.tag in globals(): + rv.append(globals()[cmd.tag](cmd, self.get_augeas(entry), + self.logger)) + else: + err = "Augeas: Unknown command %s in %s" % (cmd.tag, + entry.get("name")) + self.logger.error(err) + entry.set('qtext', "\n".join([entry.get('qtext', ''), err])) + return rv + + def verify(self, entry, modlist): + rv = True + for cmd in self.get_commands(entry): + try: + if not cmd.verify(): + err = "Augeas: Command has not been applied to %s: %s" % \ + (entry.get("name"), cmd) + self.logger.debug(err) + entry.set('qtext', "\n".join([entry.get('qtext', ''), + err])) + rv = False + cmd.command.set("verified", "false") + else: + cmd.command.set("verified", "true") + except: # pylint: disable=W0702 + err = "Augeas: Unexpected error verifying %s: %s: %s" % \ + (entry.get("name"), cmd, sys.exc_info()[1]) + self.logger.error(err) + entry.set('qtext', "\n".join([entry.get('qtext', ''), err])) + rv = False + cmd.command.set("verified", "false") + return POSIXTool.verify(self, entry, modlist) and rv + + def install(self, entry): + rv = True + for cmd in self.get_commands(entry, unverified=True): + try: + cmd.install() + except: # pylint: disable=W0702 + self.logger.error( + "Failure running Augeas command on %s: %s: %s" % + (entry.get("name"), cmd, sys.exc_info()[1])) + rv = False + try: + self.get_augeas(entry).save() + except: # pylint: disable=W0702 + self.logger.error( + "Failure saving Augeas changes to %s: %s" % + (entry.get("name"), sys.exc_info()[1])) + rv = False + return POSIXTool.install(self, entry) and rv diff --git a/testsuite/Testsrc/Testlib/TestClient/TestTools/TestPOSIX/TestAugeas.py b/testsuite/Testsrc/Testlib/TestClient/TestTools/TestPOSIX/TestAugeas.py new file mode 100644 index 000000000..bfcb8a378 --- /dev/null +++ b/testsuite/Testsrc/Testlib/TestClient/TestTools/TestPOSIX/TestAugeas.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +import os +import sys +import copy +import lxml.etree +import tempfile +from mock import Mock, MagicMock, patch +try: + from Bcfg2.Client.Tools.POSIX.Augeas import * + HAS_AUGEAS = True +except ImportError: + HAS_AUGEAS = False + +# add all parent testsuite directories to sys.path to allow (most) +# relative imports in python 2.4 +path = os.path.dirname(__file__) +while path != "/": + if os.path.basename(path).lower().startswith("test"): + sys.path.append(path) + if os.path.basename(path) == "testsuite": + break + path = os.path.dirname(path) +from TestPOSIX.Testbase import TestPOSIXTool +from common import * + + +test_data = """<Test> + <Empty/> + <Text>content with spaces</Text> + <Attrs foo="foo" bar="bar"/> + <Children identical="false"> + <Foo/> + <Bar attr="attr"/> + </Children> + <Children identical="true"> + <Thing>one</Thing> + <Thing>two</Thing> + </Children> + <Children multi="true"> + <Thing>same</Thing> + <Thing>same</Thing> + <Thing>same</Thing> + <Thing>same</Thing> + </Children> +</Test> +""" + +test_xdata = lxml.etree.XML(test_data) + +class TestPOSIXAugeas(TestPOSIXTool): + test_obj = POSIXAugeas + + applied_commands = dict( + insert=lxml.etree.Element( + "Insert", label="Thing", + path='Test/Children[#attribute/identical = "true"]/Thing'), + set=lxml.etree.Element("Set", path="Test/Text/#text", + value="content with spaces"), + move=lxml.etree.Element( + "Move", source="Test/Foo", + destination='Test/Children[#attribute/identical = "false"]/Foo'), + remove=lxml.etree.Element("Remove", path="Test/Bar"), + clear=lxml.etree.Element("Clear", path="Test/Empty/#text"), + setm=lxml.etree.Element( + "SetMulti", sub="#text", value="same", + base='Test/Children[#attribute/multi = "true"]/Thing')) + + + @skipUnless(HAS_AUGEAS, "Python Augeas libraries not found") + def setUp(self): + fd, self.tmpfile = tempfile.mkstemp() + os.fdopen(fd, 'w').write(test_data) + + def tearDown(self): + tmpfile = getattr(self, "tmpfile", None) + if tmpfile: + os.unlink(tmpfile) + + def test_fully_specified(self): + ptool = self.get_obj() + + entry = lxml.etree.Element("Path", name="/test", type="augeas") + self.assertFalse(ptool.fully_specified(entry)) + + entry.text = "text" + self.assertTrue(ptool.fully_specified(entry)) + + def test_install(self): + # this is tested adequately by the other tests + pass + + def test_verify(self): + # this is tested adequately by the other tests + pass + + @patch("Bcfg2.Client.Tools.POSIX.Augeas.POSIXTool.verify") + def _verify(self, commands, mock_verify): + ptool = self.get_obj() + mock_verify.return_value = True + + entry = lxml.etree.Element("Path", name=self.tmpfile, type="augeas", + lens="Xml") + entry.extend(commands) + + modlist = [] + self.assertTrue(ptool.verify(entry, modlist)) + mock_verify.assert_called_with(ptool, entry, modlist) + self.assertXMLEqual(lxml.etree.parse(self.tmpfile).getroot(), + test_xdata) + + def test_verify_insert(self): + """ Test successfully verifying an Insert command """ + self._verify([self.applied_commands['insert']]) + + def test_verify_set(self): + """ Test successfully verifying a Set command """ + self._verify([self.applied_commands['set']]) + + def test_verify_move(self): + """ Test successfully verifying a Move command """ + self._verify([self.applied_commands['move']]) + + def test_verify_remove(self): + """ Test successfully verifying a Remove command """ + self._verify([self.applied_commands['remove']]) + + def test_verify_clear(self): + """ Test successfully verifying a Clear command """ + self._verify([self.applied_commands['clear']]) + + def test_verify_set_multi(self): + """ Test successfully verifying a SetMulti command """ + self._verify([self.applied_commands['setm']]) + + def test_verify_all(self): + """ Test successfully verifying multiple commands """ + self._verify(self.applied_commands.values()) + + @patch("Bcfg2.Client.Tools.POSIX.Augeas.POSIXTool.install") + def _install(self, commands, expected, mock_install): + ptool = self.get_obj() + mock_install.return_value = True + + entry = lxml.etree.Element("Path", name=self.tmpfile, type="augeas", + lens="Xml") + entry.extend(commands) + + self.assertTrue(ptool.install(entry)) + mock_install.assert_called_with(ptool, entry) + self.assertXMLEqual(lxml.etree.parse(self.tmpfile).getroot(), + expected) + + def test_install_set_existing(self): + """ Test setting the value of an existing node """ + expected = copy.deepcopy(test_xdata) + expected.find("Text").text = "Changed content" + self._install([lxml.etree.Element("Set", path="Test/Text/#text", + value="Changed content")], + expected) + + def test_install_set_new(self): + """ Test setting the value of an new node """ + expected = copy.deepcopy(test_xdata) + newtext = lxml.etree.SubElement(expected, "NewText") + newtext.text = "new content" + self._install([lxml.etree.Element("Set", path="Test/NewText/#text", + value="new content")], + expected) + + def test_install_remove(self): + """ Test removing a node """ + expected = copy.deepcopy(test_xdata) + expected.remove(expected.find("Attrs")) + self._install( + [lxml.etree.Element("Remove", + path='Test/*[#attribute/foo = "foo"]')], + expected) + + def test_install_move(self): + """ Test moving a node """ + expected = copy.deepcopy(test_xdata) + foo = expected.xpath("//Foo")[0] + expected.append(foo) + self._install( + [lxml.etree.Element("Move", source='Test/Children/Foo', + destination='Test/Foo')], + expected) + + def test_install_clear(self): + """ Test clearing a node """ + # TODO: clearing a node doesn't seem to work with the XML lens + # + # % augtool -b + # augtool> set /augeas/load/Xml/incl[3] "/tmp/test.xml" + # augtool> load + # augtool> clear '/files/tmp/test.xml/Test/Text/#text' + # augtool> save + # error: Failed to execute command + # saving failed (run 'print /augeas//error' for details) + # augtool> print /augeas//error + # + # The error isn't useful. + pass + + def test_install_set_multi(self): + """ Test setting multiple nodes at once """ + expected = copy.deepcopy(test_xdata) + for thing in expected.xpath("Children[@identical='true']/Thing"): + thing.text = "same" + self._install( + [lxml.etree.Element( + "SetMulti", value="same", + base='Test/Children[#attribute/identical = "true"]', + sub="Thing/#text")], + expected) + + def test_install_insert(self): + """ Test inserting a node """ + expected = copy.deepcopy(test_xdata) + children = expected.xpath("Children[@identical='true']")[0] + thing = lxml.etree.Element("Thing") + thing.text = "three" + children.append(thing) + self._install( + [lxml.etree.Element( + "Insert", + path='Test/Children[#attribute/identical = "true"]/Thing[2]', + label="Thing", where="after"), + lxml.etree.Element( + "Set", + path='Test/Children[#attribute/identical = "true"]/Thing[3]/#text', + value="three")], + expected) diff --git a/testsuite/common.py b/testsuite/common.py index e26d0be61..536b11cbd 100644 --- a/testsuite/common.py +++ b/testsuite/common.py @@ -13,6 +13,7 @@ import re import sys import codecs import unittest +import lxml.etree from mock import patch, MagicMock, _patch, DEFAULT from Bcfg2.Compat import wraps @@ -262,24 +263,43 @@ class Bcfg2TestCase(unittest.TestCase): "%s is not less than or equal to %s") def assertXMLEqual(self, el1, el2, msg=None): - """ Test that the two XML trees given are equal. Both - elements and all children are expected to have ``name`` - attributes. """ - self.assertEqual(el1.tag, el2.tag, msg=msg) - self.assertEqual(el1.text, el2.text, msg=msg) - self.assertItemsEqual(el1.attrib.items(), el2.attrib.items(), msg=msg) + """ Test that the two XML trees given are equal. """ + if msg is None: + msg = "XML trees are not equal: %s" + else: + msg += ": %s" + fullmsg = msg + "\nFirst: %s" % lxml.etree.tostring(el1) + \ + "\nSecond: %s" % lxml.etree.tostring(el2) + + self.assertEqual(el1.tag, el2.tag, msg=fullmsg % "Tags differ") + if el1.text is not None and el2.text is not None: + self.assertEqual(el1.text.strip(), el2.text.strip(), + msg=fullmsg % "Text content differs") + else: + self.assertEqual(el1.text, el2.text, + msg=fullmsg % "Text content differs") + self.assertItemsEqual(el1.attrib.items(), el2.attrib.items(), + msg=fullmsg % "Attributes differ") self.assertEqual(len(el1.getchildren()), - len(el2.getchildren())) + len(el2.getchildren()), + msg=fullmsg % "Different numbers of children") + matched = [] for child1 in el1.getchildren(): - cname = child1.get("name") - self.assertIsNotNone(cname, - msg="Element %s has no 'name' attribute" % - child1.tag) - children2 = el2.xpath("%s[@name='%s']" % (child1.tag, cname)) - self.assertEqual(len(children2), 1, - msg="More than one %s element named %s" % \ - (child1.tag, cname)) - self.assertXMLEqual(child1, children2[0], msg=msg) + for child2 in el2.xpath(child1.tag): + if child2 in matched: + continue + try: + self.assertXMLEqual(child1, child2) + matched.append(child2) + break + except AssertionError: + continue + else: + assert False, \ + fullmsg % ("Element %s is missing from second" % + lxml.etree.tostring(child1)) + self.assertItemsEqual(el2.getchildren(), matched, + msg=fullmsg % "Second has extra element(s)") class DBModelTestCase(Bcfg2TestCase): @@ -394,4 +414,3 @@ try: re_type = re._pattern_type except AttributeError: re_type = type(re.compile("")) - |