diff options
Diffstat (limited to 'src/lib/Bcfg2/Server/Plugin/helpers.py')
-rw-r--r-- | src/lib/Bcfg2/Server/Plugin/helpers.py | 809 |
1 files changed, 400 insertions, 409 deletions
diff --git a/src/lib/Bcfg2/Server/Plugin/helpers.py b/src/lib/Bcfg2/Server/Plugin/helpers.py index 3a994606c..73ad24614 100644 --- a/src/lib/Bcfg2/Server/Plugin/helpers.py +++ b/src/lib/Bcfg2/Server/Plugin/helpers.py @@ -3,84 +3,37 @@ import os import re import sys -import copy import time +import copy import glob import logging +import genshi import operator import lxml.etree import Bcfg2.Server import Bcfg2.Options -import Bcfg2.Statistics +import Bcfg2.Server.FileMonitor +from Bcfg2.Logger import Debuggable from Bcfg2.Compat import CmpMixin, wraps -from Bcfg2.Server.Plugin.base import Debuggable, Plugin +from Bcfg2.Server.Plugin.base import Plugin from Bcfg2.Server.Plugin.interfaces import Generator from Bcfg2.Server.Plugin.exceptions import SpecificityError, \ PluginExecutionError try: + import Bcfg2.Server.Encryption + HAS_CRYPTO = True +except ImportError: + HAS_CRYPTO = False + +try: import django # pylint: disable=W0611 HAS_DJANGO = True except ImportError: HAS_DJANGO = False -#: A dict containing default metadata for Path entries from bcfg2.conf -DEFAULT_FILE_METADATA = Bcfg2.Options.OptionParser( - dict(configfile=Bcfg2.Options.CFILE, - owner=Bcfg2.Options.MDATA_OWNER, - group=Bcfg2.Options.MDATA_GROUP, - mode=Bcfg2.Options.MDATA_MODE, - secontext=Bcfg2.Options.MDATA_SECONTEXT, - important=Bcfg2.Options.MDATA_IMPORTANT, - paranoid=Bcfg2.Options.MDATA_PARANOID, - sensitive=Bcfg2.Options.MDATA_SENSITIVE)) -DEFAULT_FILE_METADATA.parse([Bcfg2.Options.CFILE.cmd, Bcfg2.Options.CFILE]) -del DEFAULT_FILE_METADATA['args'] -del DEFAULT_FILE_METADATA['configfile'] - LOGGER = logging.getLogger(__name__) -#: a compiled regular expression for parsing info and :info files -INFO_REGEX = re.compile(r'owner:\s*(?P<owner>\S+)|' + - r'group:\s*(?P<group>\S+)|' + - r'mode:\s*(?P<mode>\w+)|' + - r'secontext:\s*(?P<secontext>\S+)|' + - r'paranoid:\s*(?P<paranoid>\S+)|' + - r'sensitive:\s*(?P<sensitive>\S+)|' + - r'encoding:\s*(?P<encoding>\S+)|' + - r'important:\s*(?P<important>\S+)|' + - r'mtime:\s*(?P<mtime>\w+)') - - -def bind_info(entry, metadata, infoxml=None, default=DEFAULT_FILE_METADATA): - """ Bind the file metadata in the given - :class:`Bcfg2.Server.Plugin.helpers.InfoXML` object to the given - entry. - - :param entry: The abstract entry to bind the info to - :type entry: lxml.etree._Element - :param metadata: The client metadata to get info for - :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata - :param infoxml: The info.xml file to pull file metadata from - :type infoxml: Bcfg2.Server.Plugin.helpers.InfoXML - :param default: Default metadata to supply when the info.xml file - does not include a particular attribute - :type default: dict - :returns: None - :raises: :class:`Bcfg2.Server.Plugin.exceptions.PluginExecutionError` - """ - for attr, val in list(default.items()): - entry.set(attr, val) - if infoxml: - mdata = dict() - infoxml.pnode.Match(metadata, mdata, entry=entry) - if 'Info' not in mdata: - msg = "Failed to set metadata for file %s" % entry.get('name') - LOGGER.error(msg) - raise PluginExecutionError(msg) - for attr, val in list(mdata['Info'][None].items()): - entry.set(attr, val) - class track_statistics(object): # pylint: disable=C0103 """ Decorator that tracks execution time for the given @@ -111,45 +64,98 @@ class track_statistics(object): # pylint: disable=C0103 try: return func(obj, *args, **kwargs) finally: - Bcfg2.Statistics.stats.add_value(name, time.time() - start) + Bcfg2.Server.Statistics.stats.add_value(name, + time.time() - start) return inner +def removecomment(stream): + """ A Genshi filter that removes comments from the stream. This + function is a generator. + + :param stream: The Genshi stream to remove comments from + :type stream: genshi.core.Stream + :returns: tuple of ``(kind, data, pos)``, as when iterating + through a Genshi stream + """ + for kind, data, pos in stream: + if kind is genshi.core.COMMENT: + continue + yield kind, data, pos + + +def bind_info(entry, metadata, infoxml=None, default=None): + """ Bind the file metadata in the given + :class:`Bcfg2.Server.Plugin.helpers.InfoXML` object to the given + entry. + + :param entry: The abstract entry to bind the info to + :type entry: lxml.etree._Element + :param metadata: The client metadata to get info for + :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata + :param infoxml: The info.xml file to pull file metadata from + :type infoxml: Bcfg2.Server.Plugin.helpers.InfoXML + :param default: Default metadata to supply when the info.xml file + does not include a particular attribute + :type default: dict + :returns: None + :raises: :class:`Bcfg2.Server.Plugin.exceptions.PluginExecutionError` + """ + if default is None: + default = default_path_metadata() + for attr, val in list(default.items()): + entry.set(attr, val) + if infoxml: + mdata = dict() + infoxml.pnode.Match(metadata, mdata, entry=entry) + if 'Info' not in mdata: + msg = "Failed to set metadata for file %s" % entry.get('name') + LOGGER.error(msg) + raise PluginExecutionError(msg) + for attr, val in list(mdata['Info'][None].items()): + entry.set(attr, val) + + +def default_path_metadata(): + """ Get the default Path entry metadata from the config. + + :returns: dict of metadata attributes and their default values + """ + return dict([(k, getattr(Bcfg2.Options.setup, "default_%s" % k)) + for k in ['owner', 'group', 'mode', 'secontext', 'important', + 'paranoid', 'sensitive']]) + + class DatabaseBacked(Plugin): """ Provides capabilities for a plugin to read and write to a - database. + database. The plugin must add an option to flag database use with + something like: + + options = Bcfg2.Server.Plugin.Plugins.options + [ + Bcfg2.Options.BooleanOption( + cf=('metadata', 'use_database'), dest="metadata_db", + help="Use database capabilities of the Metadata plugin") + + This must be done manually due to various limitations in Python. .. private-include: _use_db .. private-include: _must_lock """ - #: The option to look up in :attr:`section` to determine whether or - #: not to use the database capabilities of this plugin. The option - #: is retrieved with - #: :py:func:`ConfigParser.SafeConfigParser.getboolean`, and so must - #: conform to the possible values that function can handle. - option = "use_database" - - def _section(self): - """ The section to look in for :attr:`DatabaseBacked.option` - """ - return self.name.lower() - section = property(_section) - @property def _use_db(self): """ Whether or not this plugin is configured to use the database. """ - use_db = self.core.setup.cfp.getboolean(self.section, - self.option, - default=False) + use_db = getattr(Bcfg2.Options.setup, "%s_db" % self.name.lower(), + False) if use_db and HAS_DJANGO and self.core.database_available: return True elif not use_db: return False else: - self.logger.error("%s is true but django not found" % self.option) + self.logger.error("%s: use_database is true but django not found" % + self.name) return False @property @@ -157,11 +163,7 @@ class DatabaseBacked(Plugin): """ Whether or not the backend database must acquire a thread lock before writing, because it does not allow multiple threads to write.""" - engine = \ - self.core.setup.cfp.get(Bcfg2.Options.DB_ENGINE.cf[0], - Bcfg2.Options.DB_ENGINE.cf[1], - default=Bcfg2.Options.DB_ENGINE.default) - return engine == 'sqlite3' + return self._use_db and Bcfg2.Options.setup.db_engine == 'sqlite3' @staticmethod def get_db_lock(func): @@ -198,13 +200,10 @@ class FileBacked(Debuggable): principally meant to be used as a part of :class:`Bcfg2.Server.Plugin.helpers.DirectoryBacked`. """ - def __init__(self, name, fam=None): + def __init__(self, name): """ :param name: The full path to the file to cache and monitor :type name: string - :param fam: The FAM object used to receive notifications of - changes - :type fam: Bcfg2.Server.FileMonitor.FileMonitor """ Debuggable.__init__(self) @@ -215,7 +214,7 @@ class FileBacked(Debuggable): self.name = name #: The FAM object used to receive notifications of changes - self.fam = fam + self.fam = Bcfg2.Server.FileMonitor.get_fam() def HandleEvent(self, event=None): """ HandleEvent is called whenever the FAM registers an event. @@ -268,14 +267,11 @@ class DirectoryBacked(Debuggable): #: :attr:`patterns` or ``ignore``, then a warning will be produced. ignore = None - def __init__(self, data, fam): + def __init__(self, data): """ :param data: The path to the data directory that will be monitored :type data: string - :param fam: The FAM object used to receive notifications of - changes - :type fam: Bcfg2.Server.FileMonitor.FileMonitor .. ----- .. autoattribute:: __child__ @@ -283,7 +279,7 @@ class DirectoryBacked(Debuggable): Debuggable.__init__(self) self.data = os.path.normpath(data) - self.fam = fam + self.fam = Bcfg2.Server.FileMonitor.get_fam() #: self.entries contains information about the files monitored #: by this object. The keys of the dict are the relative @@ -355,8 +351,7 @@ class DirectoryBacked(Debuggable): :returns: None """ self.entries[relative] = self.__child__(os.path.join(self.data, - relative), - self.fam) + relative)) self.entries[relative].HandleEvent(event) def HandleEvent(self, event): # pylint: disable=R0912 @@ -481,13 +476,10 @@ class XMLFileBacked(FileBacked): #: to the constructor. create = None - def __init__(self, filename, fam=None, should_monitor=False, create=None): + def __init__(self, filename, should_monitor=False, create=None): """ :param filename: The full path to the file to cache and monitor :type filename: string - :param fam: The FAM object used to receive notifications of - changes - :type fam: Bcfg2.Server.FileMonitor.FileMonitor :param should_monitor: Whether or not to monitor this file for changes. It may be useful to disable monitoring when, for instance, the file @@ -507,7 +499,7 @@ class XMLFileBacked(FileBacked): .. ----- .. autoattribute:: __identifier__ """ - FileBacked.__init__(self, filename, fam=fam) + FileBacked.__init__(self, filename) #: The raw XML data contained in the file as an #: :class:`lxml.etree.ElementTree` object, with XIncludes @@ -531,7 +523,7 @@ class XMLFileBacked(FileBacked): self.extra_monitors = [] if ((create is not None or self.create not in [None, False]) and - not os.path.exists(self.name)): + not os.path.exists(self.name)): toptag = create or self.create self.logger.warning("%s does not exist, creating" % self.name) if hasattr(toptag, "getroottree"): @@ -543,7 +535,7 @@ class XMLFileBacked(FileBacked): #: Whether or not to monitor this file for changes. self.should_monitor = should_monitor - if fam and should_monitor: + if should_monitor: self.fam.AddMonitor(filename, self) def _follow_xincludes(self, fname=None, xdata=None): @@ -605,18 +597,14 @@ class XMLFileBacked(FileBacked): Index.__doc__ = FileBacked.Index.__doc__ def add_monitor(self, fpath): - """ Add a FAM monitor to a file that has been XIncluded. This - is only done if the constructor got a ``fam`` object, - regardless of whether ``should_monitor`` is set to True (i.e., - whether or not the base file is monitored). + """ Add a FAM monitor to a file that has been XIncluded. :param fpath: The full path to the file to monitor :type fpath: string :returns: None """ self.extra_monitors.append(fpath) - if self.fam: - self.fam.AddMonitor(fpath, self) + self.fam.AddMonitor(fpath, self) def __iter__(self): return iter(self.entries) @@ -628,44 +616,176 @@ class XMLFileBacked(FileBacked): class StructFile(XMLFileBacked): """ StructFiles are XML files that contain a set of structure file formatting logic for handling ``<Group>`` and ``<Client>`` - tags. """ + tags. + + .. ----- + .. autoattribute:: __identifier__ + .. automethod:: _include_element + """ #: If ``__identifier__`` is not None, then it must be the name of #: an XML attribute that will be required on the top-level tag of #: the file being cached __identifier__ = None - def _include_element(self, item, metadata): - """ determine if an XML element matches the metadata """ + #: Whether or not to enable encryption + encryption = True + + #: Callbacks used to determine if children of items with the given + #: tags should be included in the return value of + #: :func:`Bcfg2.Server.Plugin.helpers.StructFile.Match` and + #: :func:`Bcfg2.Server.Plugin.helpers.StructFile.XMLMatch`. Each + #: callback is passed the same arguments as + #: :func:`Bcfg2.Server.Plugin.helpers.StructFile._include_element`. + #: It should return True if children of the element should be + #: included in the match, False otherwise. The callback does + #: *not* need to consider negation; that will be handled in + #: :func:`Bcfg2.Server.Plugin.helpers.StructFile._include_element` + _include_tests = \ + dict(Group=lambda el, md, *args: el.get('name') in md.groups, + Client=lambda el, md, *args: el.get('name') == md.hostname) + + def __init__(self, filename, should_monitor=False, create=None): + XMLFileBacked.__init__(self, filename, should_monitor=should_monitor, + create=create) + self.template = None + + def Index(self): + XMLFileBacked.Index(self) + if (self.name.endswith('.genshi') or + ('py' in self.xdata.nsmap and + self.xdata.nsmap['py'] == 'http://genshi.edgewall.org/')): + try: + loader = genshi.template.TemplateLoader() + self.template = \ + loader.load(self.name, + cls=genshi.template.MarkupTemplate, + encoding=Bcfg2.Options.setup.encoding) + except LookupError: + err = sys.exc_info()[1] + self.logger.error('Genshi lookup error in %s: %s' % (self.name, + err)) + except genshi.template.TemplateError: + err = sys.exc_info()[1] + self.logger.error('Genshi template error in %s: %s' % + (self.name, err)) + except genshi.input.ParseError: + err = sys.exc_info()[1] + self.logger.error('Genshi parse error in %s: %s' % (self.name, + err)) + + if HAS_CRYPTO and self.encryption: + lax_decrypt = self.xdata.get( + "lax_decryption", + str(Bcfg2.Options.setup.lax_decryption)).lower() == "true" + for el in self.xdata.xpath("//*[@encrypted]"): + try: + el.text = self._decrypt(el).encode('ascii', + 'xmlcharrefreplace') + except UnicodeDecodeError: + self.logger.info("%s: Decrypted %s to gibberish, skipping" + % (self.name, el.tag)) + except Bcfg2.Server.Encryption.EVPError: + msg = "Failed to decrypt %s element in %s" % (el.tag, + self.name) + if lax_decrypt: + self.logger.warning(msg) + else: + raise PluginExecutionError(msg) + Index.__doc__ = XMLFileBacked.Index.__doc__ + + def _decrypt(self, element): + """ Decrypt a single encrypted properties file element """ + if not element.text or not element.text.strip(): + return + passes = Bcfg2.Options.setup.passphrases + try: + passphrase = passes[element.get("encrypted")] + try: + return Bcfg2.Server.Encryption.ssl_decrypt(element.text, + passphrase) + except Bcfg2.Server.Encryption.EVPError: + # error is raised below + pass + except KeyError: + # bruteforce_decrypt raises an EVPError with a sensible + # error message, so we just let it propagate up the stack + return Bcfg2.Server.Encryption.bruteforce_decrypt(element.text) + raise Bcfg2.Server.Encryption.EVPError("Failed to decrypt") + + def _include_element(self, item, metadata, *args): + """ Determine if an XML element matches the other arguments. + + The first argument is always the XML element to match, and the + second will always be a single + :class:`Bcfg2.Server.Plugins.Metadata.ClientMetadata` object + representing the metadata to match against. Subsequent + arguments are as given to + :func:`Bcfg2.Server.Plugin.helpers.StructFile.Match` or + :func:`Bcfg2.Server.Plugin.helpers.StructFile.XMLMatch`. In + the base StructFile implementation, there are no additional + arguments; in classes that inherit from StructFile, see the + :func:`Match` and :func:`XMLMatch` method signatures.""" if isinstance(item, lxml.etree._Comment): # pylint: disable=W0212 return False - negate = item.get('negate', 'false').lower() == 'true' - if item.tag == 'Group': - return negate == (item.get('name') not in metadata.groups) - elif item.tag == 'Client': - return negate == (item.get('name') != metadata.hostname) + if item.tag in self._include_tests: + negate = item.get('negate', 'false').lower() == 'true' + return negate != self._include_tests[item.tag](item, metadata, + *args) else: return True - def _match(self, item, metadata): - """ recursive helper for Match() """ - if self._include_element(item, metadata): - if item.tag == 'Group' or item.tag == 'Client': + def _render(self, metadata): + """ Render the template for the given client metadata + + :param metadata: Client metadata to match against. + :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata + :returns: lxml.etree._Element object representing the rendered + XML data + """ + stream = self.template.generate( + metadata=metadata, + repo=Bcfg2.Options.setup.repository).filter(removecomment) + return lxml.etree.XML(stream.render('xml', + strip_whitespace=False), + parser=Bcfg2.Server.XMLParser) + + def _match(self, item, metadata, *args): + """ recursive helper for + :func:`Bcfg2.Server.Plugin.helpers.StructFile.Match` """ + if self._include_element(item, metadata, *args): + if item.tag in self._include_tests.keys(): rv = [] - if self._include_element(item, metadata): + if self._include_element(item, metadata, *args): for child in item.iterchildren(): - rv.extend(self._match(child, metadata)) + rv.extend(self._match(child, metadata, *args)) return rv else: rv = copy.deepcopy(item) for child in rv.iterchildren(): rv.remove(child) for child in item.iterchildren(): - rv.extend(self._match(child, metadata)) + rv.extend(self._match(child, metadata, *args)) return [rv] else: return [] + def _do_match(self, metadata, *args): + """ Helper for + :func:`Bcfg2.Server.Plugin.helpers.StructFile.Match` that lets + a subclass of StructFile easily redefine the public Match() + interface to accept a different number of arguments. This + provides a sane prototype for the Match() function while + keeping the internals consistent. """ + rv = [] + if self.template is None: + entries = self.entries + else: + entries = self._render(metadata).getchildren() + for child in entries: + rv.extend(self._match(child, metadata, *args)) + return rv + def Match(self, metadata): """ Return matching fragments of the data in this file. A tag is considered to match if all ``<Group>`` and ``<Client>`` @@ -676,22 +796,22 @@ class StructFile(XMLFileBacked): Match() (and *not* their descendents) should be considered to match the metadata. + Match() returns matching fragments in document order. + :param metadata: Client metadata to match against. :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata :returns: list of lxml.etree._Element objects """ - rv = [] - for child in self.entries: - rv.extend(self._match(child, metadata)) - return rv + return self._do_match(metadata) - def _xml_match(self, item, metadata): - """ recursive helper for XMLMatch """ - if self._include_element(item, metadata): - if item.tag == 'Group' or item.tag == 'Client': + def _xml_match(self, item, metadata, *args): + """ recursive helper for + :func:`Bcfg2.Server.Plugin.helpers.StructFile.XMLMatch` """ + if self._include_element(item, metadata, *args): + if item.tag in self._include_tests.keys(): for child in item.iterchildren(): item.remove(child) item.getparent().append(child) - self._xml_match(child, metadata) + self._xml_match(child, metadata, *args) if item.text: if item.getparent().text is None: item.getparent().text = item.text @@ -700,10 +820,25 @@ class StructFile(XMLFileBacked): item.getparent().remove(item) else: for child in item.iterchildren(): - self._xml_match(child, metadata) + self._xml_match(child, metadata, *args) else: item.getparent().remove(item) + def _do_xmlmatch(self, metadata, *args): + """ Helper for + :func:`Bcfg2.Server.Plugin.helpers.StructFile.XMLMatch` that lets + a subclass of StructFile easily redefine the public Match() + interface to accept a different number of arguments. This + provides a sane prototype for the Match() function while + keeping the internals consistent. """ + if self.template is None: + rv = copy.deepcopy(self.xdata) + else: + rv = self._render(metadata) + for child in rv.iterchildren(): + self._xml_match(child, metadata, *args) + return rv + def XMLMatch(self, metadata): """ Return a rebuilt XML document that only contains the matching portions of the original file. A tag is considered @@ -713,176 +848,58 @@ class StructFile(XMLFileBacked): All ``<Group>`` and ``<Client>`` tags will have been stripped out. + The new document produced by XMLMatch() is not necessarily in + the same order as the original document. + :param metadata: Client metadata to match against. :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata :returns: lxml.etree._Element """ - rv = copy.deepcopy(self.xdata) - for child in rv.iterchildren(): - self._xml_match(child, metadata) - return rv + return self._do_xmlmatch(metadata) -class INode(object): - """ INodes provide lists of things available at a particular group - intersection. INodes are deprecated; new plugins should use - :class:`Bcfg2.Server.Plugin.helpers.StructFile` instead. """ - - raw = dict( - Client="lambda m, e:'%(name)s' == m.hostname and predicate(m, e)", - Group="lambda m, e:'%(name)s' in m.groups and predicate(m, e)") - nraw = dict( - Client="lambda m, e:'%(name)s' != m.hostname and predicate(m, e)", - Group="lambda m, e:'%(name)s' not in m.groups and predicate(m, e)") - containers = ['Group', 'Client'] - ignore = [] - - def __init__(self, data, idict, parent=None): - self.data = data - self.contents = {} - if parent is None: - self.predicate = lambda m, e: True - else: - predicate = parent.predicate - if data.get('negate', 'false').lower() == 'true': - psrc = self.nraw - else: - psrc = self.raw - if data.tag in list(psrc.keys()): - self.predicate = eval(psrc[data.tag] % - {'name': data.get('name')}, - {'predicate': predicate}) - else: - raise PluginExecutionError("Unknown tag: %s" % data.tag) - self.children = [] - self._load_children(data, idict) +class InfoXML(StructFile): + """ InfoXML files contain Group, Client, and Path tags to set the + metadata (permissions, owner, etc.) of files. """ + encryption = False - def _load_children(self, data, idict): - """ load children """ - for item in data.getchildren(): - if item.tag in self.ignore: - continue - elif item.tag in self.containers: - self.children.append(self.__class__(item, idict, self)) - else: - try: - self.contents[item.tag][item.get('name')] = \ - dict(item.attrib) - except KeyError: - self.contents[item.tag] = \ - {item.get('name'): dict(item.attrib)} - if item.text: - self.contents[item.tag][item.get('name')]['__text__'] = \ - item.text - if item.getchildren(): - self.contents[item.tag][item.get('name')]['__children__'] \ - = item.getchildren() - try: - idict[item.tag].append(item.get('name')) - except KeyError: - idict[item.tag] = [item.get('name')] - - def Match(self, metadata, data, entry=lxml.etree.Element("None")): - """Return a dictionary of package mappings.""" - if self.predicate(metadata, entry): - for key in self.contents: - try: - data[key].update(self.contents[key]) - except: # pylint: disable=W0702 - data[key] = {} - data[key].update(self.contents[key]) - for child in self.children: - child.Match(metadata, data, entry=entry) - - -class InfoNode (INode): - """ :class:`Bcfg2.Server.Plugin.helpers.INode` implementation that - includes ``<Path>`` tags, suitable for use with :file:`info.xml` - files.""" - - raw = dict( - Client="lambda m, e: '%(name)s' == m.hostname and predicate(m, e)", - Group="lambda m, e: '%(name)s' in m.groups and predicate(m, e)", - Path="lambda m, e: ('%(name)s' == e.get('name') or " + - "'%(name)s' == e.get('realname')) and " + - "predicate(m, e)") - nraw = dict( - Client="lambda m, e: '%(name)s' != m.hostname and predicate(m, e)", - Group="lambda m, e: '%(name)s' not in m.groups and predicate(m, e)", - Path="lambda m, e: '%(name)s' != e.get('name') and " + - "'%(name)s' != e.get('realname') and " + - "predicate(m, e)") - containers = ['Group', 'Client', 'Path'] - - -class XMLSrc(XMLFileBacked): - """ XMLSrc files contain a - :class:`Bcfg2.Server.Plugin.helpers.INode` hierarchy that returns - matching entries. XMLSrc objects are deprecated and - :class:`Bcfg2.Server.Plugin.helpers.StructFile` should be - preferred where possible.""" - __node__ = INode - __cacheobj__ = dict - __priority_required__ = True - - def __init__(self, filename, fam=None, should_monitor=False, create=None): - XMLFileBacked.__init__(self, filename, fam, should_monitor, create) - self.items = {} - self.cache = None - self.pnode = None - self.priority = -1 + _include_tests = copy.copy(StructFile._include_tests) + _include_tests['Path'] = lambda el, md, entry, *args: \ + entry.get("name") == el.get("name") - def HandleEvent(self, _=None): - """Read file upon update.""" - try: - data = open(self.name).read() - except IOError: - msg = "Failed to read file %s: %s" % (self.name, sys.exc_info()[1]) - self.logger.error(msg) - raise PluginExecutionError(msg) - self.items = {} - try: - xdata = lxml.etree.XML(data, parser=Bcfg2.Server.XMLParser) - except lxml.etree.XMLSyntaxError: - msg = "Failed to parse file %s: %s" % (self.name, - sys.exc_info()[1]) - self.logger.error(msg) - raise PluginExecutionError(msg) - self.pnode = self.__node__(xdata, self.items) - self.cache = None - try: - self.priority = int(xdata.get('priority')) - except (ValueError, TypeError): - if self.__priority_required__: - msg = "Got bogus priority %s for file %s" % \ - (xdata.get('priority'), self.name) - self.logger.error(msg) - raise PluginExecutionError(msg) + def Match(self, metadata, entry): # pylint: disable=W0221 + """ Implementation of + :func:`Bcfg2.Server.Plugin.helpers.StructFile.Match` that + considers Path tags to allow ``info.xml`` files to set + different file metadata for different file paths. """ + return self._do_match(metadata, entry) - del xdata, data - - def Cache(self, metadata): - """Build a package dict for a given host.""" - if self.cache is None or self.cache[0] != metadata: - cache = (metadata, self.__cacheobj__()) - if self.pnode is None: - self.logger.error("Cache method called early for %s; " - "forcing data load" % self.name) - self.HandleEvent() - return - self.pnode.Match(metadata, cache[1]) - self.cache = cache - - def __str__(self): - return str(self.items) + def XMLMatch(self, metadata, entry): # pylint: disable=W0221 + """ Implementation of + :func:`Bcfg2.Server.Plugin.helpers.StructFile.XMLMatch` that + considers Path tags to allow ``info.xml`` files to set + different file metadata for different file paths. """ + return self._do_xmlmatch(metadata, entry) + def BindEntry(self, entry, metadata): + """ Bind the matching file metadata for this client and entry + to the entry. -class InfoXML(XMLSrc): - """ InfoXML files contain a - :class:`Bcfg2.Server.Plugin.helpers.InfoNode` hierarchy that - returns matching entries, suitable for use with :file:`info.xml` - files.""" - __node__ = InfoNode - __priority_required__ = False + :param entry: The abstract entry to bind the info to. This + will be modified in place + :type entry: lxml.etree._Element + :param metadata: The client metadata to get info for + :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata + :returns: None + """ + fileinfo = self.Match(metadata, entry) + if len(fileinfo) == 0: + raise PluginExecutionError("No metadata found in %s for %s" % + (self.name, entry.get('name'))) + elif len(fileinfo) > 1: + self.logger.warning("Multiple file metadata found in %s for %s" % + (self.name, entry.get('name'))) + for attr, val in fileinfo[0].attrib.items(): + entry.set(attr, val) class XMLDirectoryBacked(DirectoryBacked): @@ -898,6 +915,25 @@ class XMLDirectoryBacked(DirectoryBacked): __child__ = XMLFileBacked +class PriorityStructFile(StructFile): + """ A StructFile where each file has a priority, given as a + top-level XML attribute. """ + + def __init__(self, filename, should_monitor=False): + StructFile.__init__(self, filename, should_monitor=should_monitor) + self.priority = -1 + __init__.__doc__ = StructFile.__init__.__doc__ + + def Index(self): + StructFile.Index(self) + try: + self.priority = int(self.xdata.get('priority')) + except (ValueError, TypeError): + raise PluginExecutionError("Got bogus priority %s for file %s" % + (self.xdata.get('priority'), self.name)) + Index.__doc__ = StructFile.Index.__doc__ + + class PrioDir(Plugin, Generator, XMLDirectoryBacked): """ PrioDir handles a directory of XML files where each file has a set priority. @@ -908,42 +944,43 @@ class PrioDir(Plugin, Generator, XMLDirectoryBacked): #: The type of child objects to create for files contained within #: the directory that is tracked. Default is - #: :class:`Bcfg2.Server.Plugin.helpers.XMLSrc` - __child__ = XMLSrc + #: :class:`Bcfg2.Server.Plugin.helpers.PriorityStructFile` + __child__ = PriorityStructFile def __init__(self, core, datastore): Plugin.__init__(self, core, datastore) Generator.__init__(self) - XMLDirectoryBacked.__init__(self, self.data, self.core.fam) + XMLDirectoryBacked.__init__(self, self.data) __init__.__doc__ = Plugin.__init__.__doc__ def HandleEvent(self, event): XMLDirectoryBacked.HandleEvent(self, event) self.Entries = {} - for src in list(self.entries.values()): - for itype, children in list(src.items.items()): - for child in children: - try: - self.Entries[itype][child] = self.BindEntry - except KeyError: - self.Entries[itype] = {child: self.BindEntry} + for src in self.entries.values(): + for child in src.xdata.iterchildren(): + if child.tag in ['Group', 'Client']: + continue + if child.tag not in self.Entries: + self.Entries[child.tag] = dict() + self.Entries[child.tag][child.get("name")] = self.BindEntry HandleEvent.__doc__ = XMLDirectoryBacked.HandleEvent.__doc__ - def _matches(self, entry, metadata, rules): # pylint: disable=W0613 - """ Whether or not a given entry has a matching entry in this - PrioDir. By default this does strict matching (i.e., the - entry name is in ``rules.keys()``), but this can be overridden - to provide regex matching, etc. + def _matches(self, entry, metadata, candidate): # pylint: disable=W0613 + """ Whether or not a given candidate matches the abstract + entry given. By default this does strict matching (i.e., the + entry name matches the candidate name), but this can be + overridden to provide regex matching, etc. :param entry: The entry to find a match for :type entry: lxml.etree._Element :param metadata: The metadata to get attributes for :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata - :rules: A dict of rules to look in for a matching rule - :type rules: dict + :candidate: A candidate concrete entry to match with + :type candidate: lxml.etree._Element :returns: bool """ - return entry.get('name') in rules + return (entry.tag == candidate.tag and + entry.get('name') == candidate.get('name')) def BindEntry(self, entry, metadata): """ Bind the attributes that apply to an entry to it. The @@ -955,71 +992,40 @@ class PrioDir(Plugin, Generator, XMLDirectoryBacked): :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata :returns: None """ - attrs = self.get_attrs(entry, metadata) - for key, val in list(attrs.items()): - entry.attrib[key] = val - - def get_attrs(self, entry, metadata): - """ Get a list of attributes to add to the entry during the - bind. This is a complex method, in that it both modifies the - entry, and returns attributes that need to be added to the - entry. That seems sub-optimal, and should probably be changed - at some point. Namely: - - * The return value includes all XML attributes that need to be - added to the entry, but it does not add them. - * If text contents or child tags need to be added to the - entry, they are added to the entry in place. - - :param entry: The entry to add attributes to. - :type entry: lxml.etree._Element - :param metadata: The metadata to get attributes for - :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata - :returns: dict of <attr name>:<attr value> - :raises: :class:`Bcfg2.Server.Plugin.exceptions.PluginExecutionError` - """ + matching = [] for src in self.entries.values(): - src.Cache(metadata) - - matching = [src for src in list(self.entries.values()) - if (src.cache and - entry.tag in src.cache[1] and - self._matches(entry, metadata, - src.cache[1][entry.tag]))] + for candidate in src.XMLMatch(metadata).xpath("//%s" % entry.tag): + if self._matches(entry, metadata, candidate): + matching.append((src, candidate)) if len(matching) == 0: raise PluginExecutionError("No matching source for entry when " - "retrieving attributes for %s(%s)" % - (entry.tag, entry.attrib.get('name'))) + "retrieving attributes for %s:%s" % + (entry.tag, entry.get('name'))) elif len(matching) == 1: - index = 0 + data = matching[0][1] else: - prio = [int(src.priority) for src in matching] - if prio.count(max(prio)) > 1: - msg = "Found conflicting sources with same priority for " + \ - "%s:%s for %s" % (entry.tag, entry.get("name"), - metadata.hostname) + prio = [int(m[0].priority) for m in matching] + priority = max(prio) + if prio.count(priority) > 1: + msg = "Found conflicting sources with same priority (%s) " \ + "for %s:%s for %s" % (priority, entry.tag, + entry.get("name"), metadata.hostname) self.logger.error(msg) - self.logger.error([item.name for item in matching]) - self.logger.error("Priority was %s" % max(prio)) + self.logger.error([m[0].name for m in matching]) raise PluginExecutionError(msg) - index = prio.index(max(prio)) - for rname in list(matching[index].cache[1][entry.tag].keys()): - if self._matches(entry, metadata, [rname]): - data = matching[index].cache[1][entry.tag][rname] - break - else: - # Fall back on __getitem__. Required if override used - data = matching[index].cache[1][entry.tag][entry.get('name')] - if '__text__' in data: - entry.text = data['__text__'] - if '__children__' in data: - for item in data['__children__']: - entry.append(copy.copy(item)) + for src, candidate in matching: + if int(src.priority) == priority: + data = candidate + break + + entry.text = data.text + for item in data.getchildren(): + entry.append(copy.copy(item)) - return dict([(key, data[key]) - for key in list(data.keys()) - if not key.startswith('__')]) + for key, val in list(data.attrib.items()): + if key not in entry.attrib: + entry.attrib[key] = val class Specificity(CmpMixin): @@ -1108,11 +1114,11 @@ class Specificity(CmpMixin): return "".join(rv) -class SpecificData(object): +class SpecificData(Debuggable): """ A file that is specific to certain clients, groups, or all clients. """ - def __init__(self, name, specific, encoding): # pylint: disable=W0613 + def __init__(self, name, specific): # pylint: disable=W0613 """ :param name: The full path to the file :type name: string @@ -1121,9 +1127,8 @@ class SpecificData(object): object describing what clients this file applies to. :type specific: Bcfg2.Server.Plugin.helpers.Specificity - :param encoding: The encoding to use for data in this file - :type encoding: string """ + Debuggable.__init__(self) self.name = name self.specific = specific self.data = None @@ -1145,7 +1150,7 @@ class SpecificData(object): except UnicodeDecodeError: self.data = open(self.name, mode='rb').read() except: # pylint: disable=W0201 - LOGGER.error("Failed to read file %s" % self.name) + self.logger.error("Failed to read file %s" % self.name) class EntrySet(Debuggable): @@ -1169,7 +1174,7 @@ class EntrySet(Debuggable): #: considered a plain string and filenames must match exactly. basename_is_regex = False - def __init__(self, basename, path, entry_type, encoding): + def __init__(self, basename, path, entry_type): """ :param basename: The filename or regular expression that files in this EntrySet must match. See @@ -1184,12 +1189,10 @@ class EntrySet(Debuggable): be an object factory or similar callable. See below for the expected signature. :type entry_type: callable - :param encoding: The encoding of all files in this entry set. - :type encoding: string The ``entry_type`` callable must have the following signature:: - entry_type(filepath, specificity, encoding) + entry_type(filepath, specificity) Where the parameters are: @@ -1200,8 +1203,6 @@ class EntrySet(Debuggable): object describing what clients this file applies to. :type specific: Bcfg2.Server.Plugin.helpers.Specificity - :param encoding: The encoding to use for data in this file - :type encoding: string Additionally, the object returned by ``entry_type`` must have a ``specific`` attribute that is sortable (e.g., a @@ -1214,9 +1215,8 @@ class EntrySet(Debuggable): self.path = path self.entry_type = entry_type self.entries = {} - self.metadata = DEFAULT_FILE_METADATA.copy() + self.metadata = default_path_metadata() self.infoxml = None - self.encoding = encoding if self.basename_is_regex: base_pat = basename @@ -1233,6 +1233,12 @@ class EntrySet(Debuggable): #: be overridden on a per-entry basis in :func:`entry_init`. self.specific = re.compile(pattern) + def set_debug(self, debug): + rv = Debuggable.set_debug(self, debug) + for entry in self.entries.values(): + entry.set_debug(debug) + return rv + def get_matching(self, metadata): """ Get a list of all entries that apply to the given client. This gets all matching entries; for example, there could be an @@ -1291,7 +1297,7 @@ class EntrySet(Debuggable): """ action = event.code2str() - if event.filename in ['info', 'info.xml', ':info']: + if event.filename == 'info.xml': if action in ['exists', 'created', 'changed']: self.update_metadata(event) elif action == 'deleted': @@ -1350,8 +1356,7 @@ class EntrySet(Debuggable): self.logger.error("Could not process filename %s; ignoring" % fpath) return - self.entries[event.filename] = entry_type(fpath, spec, - self.encoding) + self.entries[event.filename] = entry_type(fpath, spec) self.entries[event.filename].handle_event(event) def specificity_from_filename(self, fname, specific=None): @@ -1396,8 +1401,8 @@ class EntrySet(Debuggable): return Specificity(**kwargs) def update_metadata(self, event): - """ Process changes to or creation of info, :info, and - info.xml files for the EntrySet. + """ Process changes to or creation of info.xml files for the + EntrySet. :param event: An event that applies to an info handled by this EntrySet @@ -1409,24 +1414,9 @@ class EntrySet(Debuggable): if not self.infoxml: self.infoxml = InfoXML(fpath) self.infoxml.HandleEvent(event) - elif event.filename in [':info', 'info']: - for line in open(fpath).readlines(): - match = INFO_REGEX.match(line) - if not match: - self.logger.warning("Failed to match line in %s: %s" % - (fpath, line)) - continue - else: - mgd = match.groupdict() - for key, value in list(mgd.items()): - if value: - self.metadata[key] = value - if len(self.metadata['mode']) == 3: - self.metadata['mode'] = "0%s" % self.metadata['mode'] def reset_metadata(self, event): - """ Reset metadata to defaults if info. :info, or info.xml are - removed. + """ Reset metadata to defaults if info.xml is removed. :param event: An event that applies to an info handled by this EntrySet @@ -1435,12 +1425,10 @@ class EntrySet(Debuggable): """ if event.filename == 'info.xml': self.infoxml = None - elif event.filename in [':info', 'info']: - self.metadata = DEFAULT_FILE_METADATA.copy() def bind_info_to_entry(self, entry, metadata): - """ Shortcut to call :func:`bind_info` with the base - info/info.xml for this EntrySet. + """ Bind the metadata for the given client in the base + info.xml for this EntrySet to the entry. :param entry: The abstract entry to bind the info to. This will be modified in place @@ -1449,7 +1437,10 @@ class EntrySet(Debuggable): :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata :returns: None """ - bind_info(entry, metadata, infoxml=self.infoxml, default=self.metadata) + for attr, val in list(self.metadata.items()): + entry.set(attr, val) + if self.infoxml is not None: + self.infoxml.BindEntry(entry, metadata) def bind_entry(self, entry, metadata): """ Return the single best fully-bound entry from the set of @@ -1499,6 +1490,8 @@ class GroupSpool(Plugin, Generator): Plugin.__init__(self, core, datastore) Generator.__init__(self) + self.fam = Bcfg2.Server.FileMonitor.get_fam() + #: See :class:`Bcfg2.Server.Plugins.interfaces.Generator` for #: details on the Entries attribute. self.Entries[self.entry_type] = {} @@ -1510,7 +1503,6 @@ class GroupSpool(Plugin, Generator): self.entries = {} self.handles = {} self.AddDirectoryMonitor('') - self.encoding = core.setup['encoding'] __init__.__doc__ = Plugin.__init__.__doc__ def add_entry(self, event): @@ -1534,8 +1526,7 @@ class GroupSpool(Plugin, Generator): dirpath = self.data + ident self.entries[ident] = self.es_cls(self.filename_pattern, dirpath, - self.es_child_cls, - self.encoding) + self.es_child_cls) self.Entries[self.entry_type][ident] = \ self.entries[ident].bind_entry if not os.path.isdir(epath): @@ -1645,5 +1636,5 @@ class GroupSpool(Plugin, Generator): if not os.path.isdir(name): self.logger.error("Failed to open directory %s" % name) return - reqid = self.core.fam.AddMonitor(name, self) + reqid = self.fam.AddMonitor(name, self) self.handles[reqid] = relative |