summaryrefslogtreecommitdiffstats
path: root/src/lib/Bcfg2/Server/Plugin
diff options
context:
space:
mode:
authorChris St. Pierre <chris.a.st.pierre@gmail.com>2012-09-06 13:55:50 -0400
committerChris St. Pierre <chris.a.st.pierre@gmail.com>2012-09-06 13:55:50 -0400
commit7d277c0d5fef3d0cdb9ca9b97cb2bca09f0f46c5 (patch)
tree16cf42edd6ffcfd703ef3621369b6605a6998031 /src/lib/Bcfg2/Server/Plugin
parentd3aa773f9f42045a0922d6c194e01d029ee53a40 (diff)
downloadbcfg2-7d277c0d5fef3d0cdb9ca9b97cb2bca09f0f46c5.tar.gz
bcfg2-7d277c0d5fef3d0cdb9ca9b97cb2bca09f0f46c5.tar.bz2
bcfg2-7d277c0d5fef3d0cdb9ca9b97cb2bca09f0f46c5.zip
Documented all plugin helper objects
Diffstat (limited to 'src/lib/Bcfg2/Server/Plugin')
-rw-r--r--src/lib/Bcfg2/Server/Plugin/__init__.py15
-rw-r--r--src/lib/Bcfg2/Server/Plugin/base.py15
-rw-r--r--src/lib/Bcfg2/Server/Plugin/exceptions.py21
-rw-r--r--src/lib/Bcfg2/Server/Plugin/helpers.py648
-rw-r--r--src/lib/Bcfg2/Server/Plugin/interfaces.py43
5 files changed, 622 insertions, 120 deletions
diff --git a/src/lib/Bcfg2/Server/Plugin/__init__.py b/src/lib/Bcfg2/Server/Plugin/__init__.py
index 487a457e6..b76dbe7a0 100644
--- a/src/lib/Bcfg2/Server/Plugin/__init__.py
+++ b/src/lib/Bcfg2/Server/Plugin/__init__.py
@@ -1,5 +1,16 @@
-""" Bcfg2 server plugin base classes, interfaces, and helper
-objects. """
+""" ``Bcfg2.Server.Plugin`` contains server plugin base classes,
+interfaces, exceptions, and helper objects. This module is split into
+a number of submodules to make it more manageable, but it imports all
+symbols from the submodules, so with the exception of some
+documentation it's not necessary to use the submodules. E.g., you can
+(and should) do::
+
+ from Bcfg2.Server.Plugin import Plugin
+
+...rather than::
+
+ from Bcfg2.Server.Plugin.base import Plugin
+"""
import os
import sys
diff --git a/src/lib/Bcfg2/Server/Plugin/base.py b/src/lib/Bcfg2/Server/Plugin/base.py
index 98427e726..3667c0e8f 100644
--- a/src/lib/Bcfg2/Server/Plugin/base.py
+++ b/src/lib/Bcfg2/Server/Plugin/base.py
@@ -5,7 +5,7 @@ import logging
class Debuggable(object):
""" Mixin to add a debugging interface to an object and expose it
- via XML-RPC on :class:`Bcfg2.Server.Plugin.Plugin` objects """
+ via XML-RPC on :class:`Bcfg2.Server.Plugin.base.Plugin` objects """
#: List of names of methods to be exposed as XML-RPC functions
__rmi__ = ['toggle_debug']
@@ -18,7 +18,8 @@ class Debuggable(object):
self.logger = logging.getLogger(name)
def toggle_debug(self):
- """ Turn debugging output on or off.
+ """ Turn debugging output on or off. This method is exposed
+ via XML-RPC.
:returns: bool - The new value of the debug flag
"""
@@ -45,7 +46,7 @@ class Plugin(Debuggable):
""" The base class for all Bcfg2 Server plugins. """
#: The name of the plugin.
- name = 'Plugin'
+ name = property(lambda s: s.__class__.__name__)
#: The email address of the plugin author.
__author__ = 'bcfg-dev@mcs.anl.gov'
@@ -68,6 +69,9 @@ class Plugin(Debuggable):
#: alphabetically by their name.
sort_order = 500
+ #: List of names of methods to be exposed as XML-RPC functions
+ __rmi__ = Debuggable.__rmi__
+
def __init__(self, core, datastore):
""" Initialize the plugin.
@@ -76,7 +80,10 @@ class Plugin(Debuggable):
:param datastore: The path to the Bcfg2 repository on the
filesystem
:type datastore: string
- :raises: Bcfg2.Server.Plugin.PluginInitError
+ :raises: :class:`Bcfg2.Server.Plugin.exceptions.PluginInitError`
+
+ .. autoattribute:: __author__
+ .. autoattribute:: __rmi__
"""
object.__init__(self)
self.Entries = {}
diff --git a/src/lib/Bcfg2/Server/Plugin/exceptions.py b/src/lib/Bcfg2/Server/Plugin/exceptions.py
index bc8c62acd..de561e8e4 100644
--- a/src/lib/Bcfg2/Server/Plugin/exceptions.py
+++ b/src/lib/Bcfg2/Server/Plugin/exceptions.py
@@ -1,36 +1,37 @@
""" Exceptions for Bcfg2 Server Plugins."""
class PluginInitError(Exception):
- """Error raised in cases of :class:`Bcfg2.Server.Plugin.Plugin`
- initialization errors."""
+ """Error raised in cases of
+ :class:`Bcfg2.Server.Plugin.base.Plugin` initialization errors."""
pass
class PluginExecutionError(Exception):
- """Error raised in case of :class:`Bcfg2.Server.Plugin.Plugin`
- execution errors."""
+ """Error raised in case of
+ :class:`Bcfg2.Server.Plugin.base.Plugin` execution errors."""
pass
class MetadataConsistencyError(Exception):
- """This error gets raised when metadata is internally inconsistent."""
+ """This error gets raised when metadata is internally
+ inconsistent."""
pass
class MetadataRuntimeError(Exception):
"""This error is raised when the metadata engine is called prior
to reading enough data, or for other
- :class:`Bcfg2.Server.Plugin.Metadata` errors. """
+ :class:`Bcfg2.Server.Plugin.interfaces.Metadata` errors."""
pass
class ValidationError(Exception):
""" Exception raised by
- :class:`Bcfg2.Server.Plugin.StructureValidator` and
- :class:`Bcfg2.Server.Plugin.GoalValidator` objects """
+ :class:`Bcfg2.Server.Plugin.interfaces.StructureValidator` and
+ :class:`Bcfg2.Server.Plugin.interfaces.GoalValidator` objects """
class SpecificityError(Exception):
- """ Thrown by :class:`Bcfg2.Server.Plugin.Specificity` in case of
- filename parse failure."""
+ """ Thrown by :class:`Bcfg2.Server.Plugin.helpers.Specificity` in
+ case of filename parse failure."""
pass
diff --git a/src/lib/Bcfg2/Server/Plugin/helpers.py b/src/lib/Bcfg2/Server/Plugin/helpers.py
index 74cf4b3c4..51ff36b97 100644
--- a/src/lib/Bcfg2/Server/Plugin/helpers.py
+++ b/src/lib/Bcfg2/Server/Plugin/helpers.py
@@ -28,12 +28,15 @@ opts = {'owner': Bcfg2.Options.MDATA_OWNER,
'important': Bcfg2.Options.MDATA_IMPORTANT,
'paranoid': Bcfg2.Options.MDATA_PARANOID,
'sensitive': Bcfg2.Options.MDATA_SENSITIVE}
+
+#: A dict containing default metadata for Path entries
default_file_metadata = Bcfg2.Options.OptionParser(opts)
default_file_metadata.parse([])
del default_file_metadata['args']
logger = logging.getLogger(__name__)
+#: a compiled regular expression for parsing info and :info files
info_regex = re.compile('owner:(\s)*(?P<owner>\S+)|' +
'group:(\s)*(?P<group>\S+)|' +
'perms:(\s)*(?P<perms>\w+)|' +
@@ -45,6 +48,21 @@ info_regex = re.compile('owner:(\s)*(?P<owner>\S+)|' +
'mtime:(\s)*(?P<mtime>\w+)|')
def bind_info(entry, metadata, infoxml=None, default=default_file_metadata):
+ """ Bind the file metadata in the given :class:`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.Plugins.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:
@@ -59,40 +77,75 @@ def bind_info(entry, metadata, infoxml=None, default=default_file_metadata):
class DatabaseBacked(Plugin):
+ """ Provides capabilities for a plugin to read and write to a
+ database."""
+
+ #: 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):
- use_db = self.core.setup.cfp.getboolean(self.name.lower(),
- "use_database",
+ """ Whether or not this plugin is configured to use the
+ database.
+
+ .. private-include"""
+ use_db = self.core.setup.cfp.getboolean(self.section,
+ self.option,
default=False)
if use_db and has_django and self.core.database_available:
return True
elif not use_db:
return False
else:
- self.logger.error("use_database is true but django not found")
+ self.logger.error("%s is true but django not found" % self.option)
return False
class PluginDatabaseModel(object):
+ """ A database model mixin that all database models used by
+ :class:`DatabaseBacked` plugins must inherit from. This is just a
+ mixin; models must also inherit from django.db.models.Model to be
+ valid Django models."""
+
class Meta:
app_label = "Server"
class FileBacked(object):
- """This object caches file data in memory.
- HandleEvent is called whenever fam registers an event.
- Index can parse the data into member data as required.
- This object is meant to be used as a part of DirectoryBacked.
- """
+ """ This object caches file data in memory. FileBacked objects are
+ principally meant to be used as a part of
+ :class:`DirectoryBacked`. """
def __init__(self, name, fam=None):
+ """
+ :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
+ """
object.__init__(self)
self.data = ''
self.name = name
self.fam = fam
def HandleEvent(self, event=None):
- """Read file upon update."""
+ """ HandleEvent is called whenever the FAM registers an event.
+
+ :param event: The event object
+ :type event: Bcfg2.Server.FileMonitor.Event
+ :returns: None
+ """
if event and event.code2str() not in ['exists', 'changed', 'created']:
return
try:
@@ -103,7 +156,9 @@ class FileBacked(object):
logger.error("Failed to read file %s: %s" % (self.name, err))
def Index(self):
- """Update local data structures based on current file state"""
+ """ Index() is called by :func:`HandleEvent` every time the
+ data changes, and can parse the data into usable data as
+ required."""
pass
def __repr__(self):
@@ -111,36 +166,54 @@ class FileBacked(object):
class DirectoryBacked(object):
- """This object is a coherent cache for a filesystem hierarchy of files."""
+ """ DirectoryBacked objects represent a directory that contains
+ files, represented by objects of the type listed in
+ :attr:`__child__`, and other directories recursively. It monitors
+ for new files and directories to be added, and creates new objects
+ as required to track those."""
+
+ #: The type of child objects to create for files contained within
+ #: the directory that is tracked. Default is
+ #: :class:`FileBacked`
__child__ = FileBacked
+
+ #: Only track and include files whose names (not paths) match this
+ #: compiled regex.
patterns = re.compile('.*')
+
+ #: Preemptively ignore files whose names (not paths) match this
+ #: compiled regex. ``ignore`` can be set to ``None`` to ignore no
+ #: files. If a file is encountered that does not match
+ #: :attr:`patterns` or ``ignore``, then a warning will be produced.
ignore = None
def __init__(self, data, fam):
- """Initialize the DirectoryBacked object.
-
- :param self: the object being initialized.
- :param data: the path to the data directory that will be
- monitored.
- :param fam: The FileMonitor object used to receive
- notifications of changes.
+ """
+ :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__
"""
object.__init__(self)
self.data = os.path.normpath(data)
self.fam = fam
- # self.entries contains information about the files monitored
- # by this object.... The keys of the dict are the relative
- # paths to the files. The values are the objects (of type
- # __child__) that handle their contents.
+ #: self.entries contains information about the files monitored
+ #: by this object. The keys of the dict are the relative
+ #: paths to the files. The values are the objects (of type
+ #: :attr:`__child__`) that handle their contents.
self.entries = {}
- # self.handles contains information about the directories
- # monitored by this object. The keys of the dict are the
- # values returned by the initial fam.AddMonitor() call (which
- # appear to be integers). The values are the relative paths of
- # the directories.
+ #: self.handles contains information about the directories
+ #: monitored by this object. The keys of the dict are the
+ #: values returned by the initial fam.AddMonitor() call (which
+ #: appear to be integers). The values are the relative paths of
+ #: the directories.
self.handles = {}
# Monitor everything in the plugin's directory
@@ -153,11 +226,14 @@ class DirectoryBacked(object):
return iter(list(self.entries.items()))
def add_directory_monitor(self, relative):
- """Add a new directory to FAM structures for monitoring.
+ """ Add a new directory to the FAM for monitoring.
:param relative: Path name to monitor. This must be relative
- to the plugin's directory. An empty string value ("") will
- cause the plugin directory itself to be monitored.
+ to the plugin's directory. An empty string
+ value ("") will cause the plugin directory
+ itself to be monitored.
+ :type relative: string
+ :returns: None
"""
dirpathname = os.path.join(self.data, relative)
if relative not in self.handles.values():
@@ -168,12 +244,15 @@ class DirectoryBacked(object):
self.handles[reqid] = relative
def add_entry(self, relative, event):
- """Add a new file to our structures for monitoring.
+ """ Add a new file to our tracked entries, and to our FAM for
+ monitoring.
:param relative: Path name to monitor. This must be relative
- to the plugin's directory.
- :param event: File Monitor event that caused this entry to be
- added.
+ to the plugin's directory.
+ :type relative: string:
+ :param event: FAM event that caused this entry to be added.
+ :type event: Bcfg2.Server.FileMonitor.Event
+ :returns: None
"""
self.entries[relative] = self.__child__(os.path.join(self.data,
relative),
@@ -181,16 +260,21 @@ class DirectoryBacked(object):
self.entries[relative].HandleEvent(event)
def HandleEvent(self, event):
- """Handle FAM/Gamin events.
+ """ Handle FAM events.
- This method is invoked by FAM/Gamin when it detects a change
- to a filesystem object we have requsted to be monitored.
+ This method is invoked by the FAM when it detects a change to
+ a filesystem object we have requsted to be monitored.
This method manages the lifecycle of events related to the
- monitored objects, adding them to our indiciess and creating
- objects of type __child__ that actually do the domain-specific
- processing. When appropriate, it propogates events those
- objects by invoking their HandleEvent in turn.
+ monitored objects, adding them to our list of entries and
+ creating objects of type :attr:`__child__` that actually do
+ the domain-specific processing. When appropriate, it
+ propogates events those objects by invoking their HandleEvent
+ method in turn.
+
+ :param event: FAM event that caused this entry to be added.
+ :type event: Bcfg2.Server.FileMonitor.Event
+ :returns: None
"""
action = event.code2str()
@@ -282,13 +366,31 @@ class DirectoryBacked(object):
class XMLFileBacked(FileBacked):
+ """ This object is caches XML file data in memory. It can be used
+ as a standalone object or as a part of :class:`XMLDirectoryBacked`
"""
- This object is a coherent cache for an XML file to be used as a
- part of DirectoryBacked.
- """
+
+ #: 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__ = 'name'
def __init__(self, filename, fam=None, should_monitor=False):
+ """
+ :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
+ is monitored by another object (e.g.,
+ an :class:`XMLDirectoryBacked` object).
+ :type should_monitor: bool
+
+ .. autoattribute:: __identifier__
+ """
FileBacked.__init__(self, filename)
self.label = ""
self.entries = []
@@ -299,7 +401,7 @@ class XMLFileBacked(FileBacked):
self.fam.AddMonitor(filename, self)
def _follow_xincludes(self, fname=None, xdata=None):
- ''' follow xincludes, adding included files to self.extras '''
+ """ follow xincludes, adding included files to self.extras """
if xdata is None:
if fname is None:
xdata = self.xdata.getroottree()
@@ -329,7 +431,6 @@ class XMLFileBacked(FileBacked):
self.logger.warning(msg)
def Index(self):
- """Build local data structures."""
try:
self.xdata = lxml.etree.XML(self.data, base_url=self.name,
parser=Bcfg2.Server.XMLParser)
@@ -349,8 +450,17 @@ class XMLFileBacked(FileBacked):
self.entries = self.xdata.getchildren()
if self.__identifier__ is not None:
self.label = self.xdata.attrib[self.__identifier__]
+ 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 both a ``fam`` object and
+ ``should_monitor`` set to True.
+
+ :param fpath: The full path to the file to monitor
+ :type fpath: string
+ :returns: None
+ """
self.extras.append(fpath)
if self.fam and self.should_monitor:
self.fam.AddMonitor(fpath, self)
@@ -363,7 +473,13 @@ class XMLFileBacked(FileBacked):
class StructFile(XMLFileBacked):
- """This file contains a set of structure file formatting logic."""
+ """ StructFiles are XML files that contain a set of structure file
+ formatting logic for handling ``<Group>`` and ``<Client>``
+ tags. """
+
+ #: 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):
@@ -398,7 +514,18 @@ class StructFile(XMLFileBacked):
return []
def Match(self, metadata):
- """Return matching fragments of independent."""
+ """ Return matching fragments of the data in this file. A tag
+ is considered to match if all ``<Group>`` and ``<Client>``
+ tags that are its ancestors match the metadata given. Since
+ tags are included unmodified, it's possible for a tag to
+ itself match while containing non-matching children.
+ Consequently, only the tags contained in the list returned by
+ Match() (and *not* their descendents) should be considered to
+ match the metadata.
+
+ :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))
@@ -421,7 +548,16 @@ class StructFile(XMLFileBacked):
def XMLMatch(self, metadata):
""" Return a rebuilt XML document that only contains the
- matching portions """
+ matching portions of the original file. A tag is considered
+ to match if all ``<Group>`` and ``<Client>`` tags that are its
+ ancestors match the metadata given. Unlike :func:`Match`, the
+ document returned by XMLMatch will only contain matching data.
+ All ``<Group>`` and ``<Client>`` tags will have been stripped
+ out.
+
+ :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)
@@ -429,10 +565,10 @@ class StructFile(XMLFileBacked):
class INode(object):
- """
- LNodes provide lists of things available at a particular
- group intersection.
- """
+ """ INodes provide lists of things available at a particular group
+ intersection. INodes are deprecated; new plugins should use
+ :class:`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)")
@@ -500,7 +636,9 @@ class INode(object):
class InfoNode (INode):
- """ INode implementation that includes <Path> tags """
+ """ :class:`INode` implementation that includes ``<Path>`` tags,
+ suitable for use with :file:`info.xml` files. """
+
raw = {'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)"}
@@ -511,7 +649,9 @@ class InfoNode (INode):
class XMLSrc(XMLFileBacked):
- """XMLSrc files contain a LNode hierarchy that returns matching entries."""
+ """ XMLSrc files contain a :class:`INode` hierarchy that returns
+ matching entries. XMLSrc objects are deprecated and
+ :class:`StructFile` should be preferred where possible."""
__node__ = INode
__cacheobj__ = dict
__priority_required__ = True
@@ -567,28 +707,43 @@ class XMLSrc(XMLFileBacked):
class InfoXML(XMLSrc):
+ """ InfoXML files contain a :class:`InfoNode` hierarchy that
+ returns matching entries, suitable for use with :file:`info.xml`
+ files. """
__node__ = InfoNode
__priority_required__ = False
class XMLDirectoryBacked(DirectoryBacked):
- """Directorybacked for *.xml."""
+ """ :class:`DirectoryBacked` for XML files. """
+
+ #: Only track and include files whose names (not paths) match this
+ #: compiled regex.
patterns = re.compile('^.*\.xml$')
+
+ #: The type of child objects to create for files contained within
+ #: the directory that is tracked. Default is
+ #: :class:`XMLFileBacked`
__child__ = XMLFileBacked
class PrioDir(Plugin, Generator, XMLDirectoryBacked):
- """This is a generator that handles package assignments."""
- name = 'PrioDir'
+ """ PrioDir handles a directory of XML files where each file has a
+ set priority. """
+
+ #: The type of child objects to create for files contained within
+ #: the directory that is tracked. Default is
+ #: :class:`XMLSrc`
__child__ = XMLSrc
def __init__(self, core, datastore):
Plugin.__init__(self, core, datastore)
Generator.__init__(self)
XMLDirectoryBacked.__init__(self, self.data, self.core.fam)
+ __init__.__doc__ = \
+ Plugin.__init__.__doc__ + "\n\n.. autoattribute:: __child__"
def HandleEvent(self, event):
- """Handle events and update dispatch table."""
XMLDirectoryBacked.HandleEvent(self, event)
self.Entries = {}
for src in list(self.entries.values()):
@@ -598,17 +753,44 @@ class PrioDir(Plugin, Generator, XMLDirectoryBacked):
self.Entries[itype][child] = self.BindEntry
except KeyError:
self.Entries[itype] = {child: self.BindEntry}
+ HandleEvent.__doc__ = XMLDirectoryBacked.HandleEvent.__doc__
def _matches(self, entry, metadata, rules):
return entry.get('name') in rules
def BindEntry(self, entry, metadata):
+ """ Bind the attributes that apply to an entry to it. The
+ entry is modified 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: 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 """
+ """ 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`
+ """
for src in self.entries.values():
src.Cache(metadata)
@@ -651,8 +833,35 @@ class PrioDir(Plugin, Generator, XMLDirectoryBacked):
class Specificity(CmpMixin):
+ """ Represent the specificity of an object; i.e., what client(s)
+ it applies to. It can be group- or client-specific, or apply to
+ all clients.
+
+ Specificity objects are sortable; objects that are less specific
+ are considered less than objects that are more specific. Objects
+ that apply to all clients are the least specific; objects that
+ apply to a single client are the most specific. Objects that
+ apply to groups are sorted by priority. """
+
def __init__(self, all=False, group=False, hostname=False, prio=0,
delta=False):
+ """
+ :param all: The object applies to all clients.
+ :type all: bool
+ :param group: The object applies only to the given group.
+ :type group: string or False
+ :param hostname: The object applies only to the named client.
+ :type hostname: string or False
+ :param prio: The object has the given priority relative to
+ other objects that also apply to the same group.
+ ``<group>`` must be specified with ``<prio>``.
+ :type prio: int
+ :param delta: The object is a delta (i.e., a .cat or .diff
+ file, not a full file). Deltas are deprecated.
+ :type delta: bool
+
+ Exactly one of {all|group|hostname} should be given.
+ """
CmpMixin.__init__(self)
self.hostname = hostname
self.all = all
@@ -661,9 +870,18 @@ class Specificity(CmpMixin):
self.delta = delta
def matches(self, metadata):
- return self.all or \
- self.hostname == metadata.hostname or \
- self.group in metadata.groups
+ """ Return True if the object described by this Specificity
+ object applies to the given client. That is, if this
+ Specificity applies to all clients, or to a group the client
+ is a member of, or to the client individually.
+
+ :param metadata: The client metadata
+ :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata
+ :returns: bool
+ """
+ return (self.all or
+ self.hostname == metadata.hostname or
+ self.group in metadata.groups)
def __cmp__(self, other):
"""Sort most to least specific."""
@@ -701,11 +919,32 @@ class Specificity(CmpMixin):
class SpecificData(object):
+ """ A file that is specific to certain clients, groups, or all
+ clients. """
+
def __init__(self, name, specific, encoding):
+ """
+ :param name: The full path to the file
+ :type name: string
+ :param specific: A :class:`Specificity` object describing what
+ clients this file applies to.
+ :type specific: Bcfg2.Server.Plugins.helpers.Specificity
+ :param encoding: The encoding to use for data in this file
+ :type encoding: string
+ """
+
self.name = name
self.specific = specific
def handle_event(self, event):
+ """ Handle a FAM event. Note that the SpecificData object
+ itself has no FAM, so this must be produced by a parent object
+ (e.g., :class:`EntrySet`).
+
+ :param event: The event that applies to this file
+ :type event: Bcfg2.Server.FileMonitor.Event
+ :returns: None
+ """
if event.code2str() == 'deleted':
return
try:
@@ -717,11 +956,65 @@ class SpecificData(object):
class EntrySet(Debuggable):
- """Entry sets deal with the host- and group-specific entries."""
+ """ EntrySets deal with a collection of host- and group-specific
+ files (e.g., :class:`SpecificData` objects) in a single
+ directory. EntrySets are usually used as part of
+ :class:`GroupSpool` objects."""
+
+ #: Preemptively ignore files whose names (not paths) match this
+ #: compiled regex. ``ignore`` cannot be set to ``None``. If a
+ #: file is encountered that does not match the ``basename``
+ #: argument passed to the constructor or : ``ignore``, then a
+ #: warning will be produced.
ignore = re.compile("^(\.#.*|.*~|\\..*\\.(sw[px])|.*\\.genshi_include)$")
- basename_is_regex=False
+
+ # The ``basename`` argument passed to the constructor will be
+ #: processed as a string that contains a regular expression (i.e.,
+ #: *not* a compiled regex object) if ``basename_is_regex`` is True,
+ #: and all files that match the regex will be cincluded in the
+ #: EntrySet. If ``basename_is_regex`` is False, then it will be
+ #: considered a plain string and filenames must match exactly.
+ basename_is_regex = False
def __init__(self, basename, path, entry_type, encoding):
+ """
+ :param basename: The filename or regular expression that files
+ in this EntrySet must match. See
+ :attr:`basename_is_regex` for more details.
+ :type basename: string
+ :param path: The full path to the directory containing files
+ for this EntrySet
+ :type path: string
+ :param entry_type: A callable that returns an object that
+ represents files in this EntrySet. This
+ will usually be a class object, but it can
+ 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)
+
+ Where the parameters are:
+
+ :param filepath: Full path to file
+ :type filepath: string
+ :param specific: A :class:`Specificity` object describing what
+ clients this file applies to.
+ :type specific: Bcfg2.Server.Plugins.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
+ :class:`Specificity` object).
+
+ See :class:`SpecificData` for an example of a class that can
+ be used as an ``entry_type``.
+ """
Debuggable.__init__(self, name=basename)
self.path = path
self.entry_type = entry_type
@@ -736,18 +1029,48 @@ class EntrySet(Debuggable):
base_pat = re.escape(basename)
pattern = '(.*/)?%s(\.((H_(?P<hostname>\S+))|' % base_pat
pattern += '(G(?P<prio>\d+)_(?P<group>\S+))))?$'
- self.specific = re.compile(pattern)
- def sort_by_specific(self, one, other):
- return cmp(one.specific, other.specific)
+ #: ``specific`` is a regular expression that is used to
+ #: determine the specificity of a file in this entry set. It
+ #: must have three named groups: ``hostname``, ``prio`` (the
+ #: priority of a group-specific file), and ``group``. The base
+ #: regex is constructed from the ``basename`` argument. It can
+ #: be overridden on a per-entry basis in :func:`entry_init`.
+ self.specific = re.compile(pattern)
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
+ entry that applies to all clients, multiple group-specific
+ entries, and a client-specific entry, all of which would be
+ returned by get_matching(). You can use :func:`best_matching`
+ to get the single best matching entry.
+
+ :param metadata: The client metadata to get matching entries for
+ :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata
+ :returns: list -- all matching ``entry_type`` objects (see the
+ constructor docs for more details)
+ """
return [item for item in list(self.entries.values())
if item.specific.matches(metadata)]
def best_matching(self, metadata, matching=None):
- """ Return the appropriate interpreted template from the set of
- available templates. """
+ """ Return the single most specific matching entry from the
+ set of matching entries. You can use :func:`get_matching` to
+ get all matching entries.
+
+ :param metadata: The client metadata to get matching entries for
+ :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata
+ :param matching: The set of matching entries to pick from. If
+ this is not provided, :func:`get_matching`
+ will be called.
+ :type matching: list of ``entry_type`` objects (see the constructor
+ docs for more details)
+ :returns: a single object from the list of matching
+ ``entry_type`` objects
+ :raises: :class:`Bcfg2.Server.Plugin.exceptions.PluginExecutionError`
+ if no matching entries are found
+ """
if matching is None:
matching = self.get_matching(metadata)
@@ -760,7 +1083,16 @@ class EntrySet(Debuggable):
metadata.hostname))
def handle_event(self, event):
- """Handle FAM events for the TemplateSet."""
+ """ Handle a FAM event. This will probably be handled by a
+ call to :func:`update_metadata`, :func:`reset_metadata`,
+ :func:`entry_init`, or to the ``entry_type``
+ ``handle_event()`` function.
+
+ :param event: An event that applies to a file handled by this
+ EntrySet
+ :type event: Bcfg2.Server.FileMonitor.Event
+ :returns: None
+ """
action = event.code2str()
if event.filename in ['info', 'info.xml', ':info']:
@@ -787,7 +1119,26 @@ class EntrySet(Debuggable):
del self.entries[event.filename]
def entry_init(self, event, entry_type=None, specific=None):
- """Handle template and info file creation."""
+ """ Handle the creation of a file on the filesystem and the
+ creation of an object in this EntrySet to track it.
+
+ :param event: An event that applies to a file handled by this
+ EntrySet
+ :type event: Bcfg2.Server.FileMonitor.Event
+ :param entry_type: Override the default ``entry_type`` for
+ this EntrySet object and create a different
+ object for this entry. See the constructor
+ docs for more information on
+ ``entry_type``.
+ :type entry_type: callable
+ :param specific: Override the default :attr:`specific` regular
+ expression used by this object with a custom
+ regular expression that will be used to
+ determine the specificity of this entry.
+ :type specific: compiled regular expression object
+ :returns: None
+ :raises: :class:`Bcfg2.Server.Plugin.exceptions.SpecificityError`
+ """
if entry_type is None:
entry_type = self.entry_type
@@ -808,7 +1159,27 @@ class EntrySet(Debuggable):
self.entries[event.filename].handle_event(event)
def specificity_from_filename(self, fname, specific=None):
- """Construct a specificity instance from a filename and regex."""
+ """ Construct a :class:`Specificity` object from a filename
+ and regex. See :attr:`specific` for details on the regex.
+
+ :param fname: The filename (not full path) of a file that is
+ in this EntrySet's directory. It is not
+ necessary to determine first if the filename
+ matches this EntrySet's basename; that can be
+ done by catching
+ :class:`Bcfg2.Server.Plugin.exceptions.SpecificityError`
+ from this function.
+ :type fname: string
+ :param specific: Override the default :attr:`specific` regular
+ expression used by this object with a custom
+ regular expression that will be used to
+ determine the specificity of this entry.
+ :type specific: compiled regular expression object
+ :returns: Object representing the specificity of the file
+ :rtype: :class:`Specificity`
+ :raises: :class:`Bcfg2.Server.Plugin.exceptions.SpecificityError`
+ if the regex does not match the filename
+ """
if specific is None:
specific = self.specific
data = specific.match(fname)
@@ -827,7 +1198,14 @@ class EntrySet(Debuggable):
return Specificity(**kwargs)
def update_metadata(self, event):
- """Process info and info.xml files for the templates."""
+ """ Process changes to or creation of info, :info, and
+ info.xml files for the EntrySet.
+
+ :param event: An event that applies to an info handled by this
+ EntrySet
+ :type event: Bcfg2.Server.FileMonitor.Event
+ :returns: None
+ """
fpath = os.path.join(self.path, event.filename)
if event.filename == 'info.xml':
if not self.infoxml:
@@ -849,29 +1227,70 @@ class EntrySet(Debuggable):
self.metadata['perms'] = "0%s" % self.metadata['perms']
def reset_metadata(self, event):
- """Reset metadata to defaults if info or info.xml removed."""
+ """ Reset metadata to defaults if info. :info, or info.xml are
+ removed.
+
+ :param event: An event that applies to an info handled by this
+ EntrySet
+ :type event: Bcfg2.Server.FileMonitor.Event
+ :returns: None
+ """
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.
+
+ :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
+ :returns: None
+ """
bind_info(entry, metadata, infoxml=self.infoxml, default=self.metadata)
def bind_entry(self, entry, metadata):
- """Return the appropriate interpreted template from the set of
- available templates."""
+ """ Return the single best fully-bound entry from the set of
+ available entries for the specified client.
+
+ :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
+ :returns: lxml.etree._Element - the fully-bound entry
+ """
self.bind_info_to_entry(entry, metadata)
return self.best_matching(metadata).bind_entry(entry, metadata)
class GroupSpool(Plugin, Generator):
- """Unified interface for handling group-specific data (e.g. .G## files)."""
- name = 'GroupSpool'
- __author__ = 'bcfg-dev@mcs.anl.gov'
+ """ A GroupSpool is a collection of :class:`EntrySet` objects --
+ i.e., a directory tree, each directory in which may contain files
+ that are specific to groups/clients/etc. """
+
+ #: ``filename_pattern`` is used as the ``basename`` argument to the
+ #: :attr:`es_cls` callable. It may or may not be a regex,
+ #: depending on the :attr:`EntrySet.basename_is_regex` setting.
filename_pattern = ""
+
+ #: ``es_child_cls`` is a callable that will be used as the
+ #: ``entry_type`` argument to the :attr:`es_cls` callable. It must
+ #: return objects that will represent individual files in the
+ #: GroupSpool. For instance, :class:`SpecificData`.
es_child_cls = object
+
+ #: ``es_cls`` is a callable that must return objects that will be
+ #: used to represent directories (i.e., sets of entries) within the
+ #: GroupSpool. E.g., :class:`EntrySet`. The returned object must
+ #: implement a callable called ``bind_entry`` that has the same
+ #: signature as :attr:`EntrySet.bind_entry`.
es_cls = EntrySet
+
+ #: The entry type (i.e., the XML tag) handled by this GroupSpool
+ #: object.
entry_type = 'Path'
def __init__(self, core, datastore):
@@ -879,13 +1298,34 @@ class GroupSpool(Plugin, Generator):
Generator.__init__(self)
if self.data[-1] == '/':
self.data = self.data[:-1]
+
+ #: See :class:`Bcfg2.Server.Plugins.interfaces.Generator` for
+ #: details on the Entries attribute.
self.Entries[self.entry_type] = {}
+
+ #: ``entries`` is a dict whose keys are :func:`event_id` return
+ #: values and whose values are :attr:`es_cls` objects. It ties
+ #: the directories handled by this GroupSpools to the
+ #: :attr:`es_cls` objects that handle each directory.
self.entries = {}
self.handles = {}
self.AddDirectoryMonitor('')
self.encoding = core.encoding
+ __init__.__doc__ = Plugin.__init__.__doc__
def add_entry(self, event):
+ """ This method handles two functions:
+
+ * Adding a new entry of type :attr:`es_cls` to track a new
+ directory.
+ * Passing off an event on a file to the correct entry object
+ to handle it.
+
+ :param event: An event that applies to a file or directory
+ handled by this GroupSpool
+ :type event: Bcfg2.Server.FileMonitor.Event
+ :returns: None
+ """
epath = self.event_path(event)
ident = self.event_id(event)
if os.path.isdir(epath):
@@ -903,11 +1343,36 @@ class GroupSpool(Plugin, Generator):
self.entries[ident].handle_event(event)
def event_path(self, event):
+ """ Return the full path to the filename affected by an event.
+ :class:`Bcfg2.Server.FileMonitor.Event` objects just contain
+ the filename, not the full path, so this function reconstructs
+ the fill path based on the path to the :attr:`es_cls` object
+ that handles the event.
+
+ :param event: An event that applies to a file or directory
+ handled by this GroupSpool
+ :type event: Bcfg2.Server.FileMonitor.Event
+ :returns: string
+ """
return os.path.join(self.data,
self.handles[event.requestID].lstrip("/"),
event.filename)
def event_id(self, event):
+ """ Return a string that can be used to relate the event
+ unambiguously to a single :attr:`es_cls` object in the
+ :attr:`entries` dict. In practice, this means:
+
+ * If the event is on a directory, ``event_id`` returns the
+ full path to the directory.
+ * If the event is on a file, ``event_id`` returns the full
+ path to the directory the file is in.
+
+ :param event: An event that applies to a file or directory
+ handled by this GroupSpool
+ :type event: Bcfg2.Server.FileMonitor.Event
+ :returns: string
+ """
epath = self.event_path(event)
if os.path.isdir(epath):
return os.path.join(self.handles[event.requestID].lstrip("/"),
@@ -920,9 +1385,21 @@ class GroupSpool(Plugin, Generator):
if hasattr(entry, "toggle_debug"):
entry.toggle_debug()
return Plugin.toggle_debug(self)
+ toggle_debug.__doc__ = Plugin.toggle_debug.__doc__
def HandleEvent(self, event):
- """Unified FAM event handler for GroupSpool."""
+ """ HandleEvent is the event dispatcher for GroupSpool
+ objects. It receives all events and dispatches them the
+ appropriate handling object (e.g., one of the :attr:`es_cls`
+ objects in :attr:`entries`), function (e.g.,
+ :func:`add_entry`), or behavior (e.g., deleting an entire
+ entry set).
+
+ :param event: An event that applies to a file or directory
+ handled by this GroupSpool
+ :type event: Bcfg2.Server.FileMonitor.Event
+ :returns: None
+ """
action = event.code2str()
if event.filename[0] == '/':
return
@@ -953,7 +1430,14 @@ class GroupSpool(Plugin, Generator):
ident)
def AddDirectoryMonitor(self, relative):
- """Add new directory to FAM structures."""
+ """ Add a FAM monitor to a new directory and set the
+ appropriate event handler.
+
+ :param relative: The path to the directory relative to the
+ base data directory of the GroupSpool object.
+ :type relative: string
+ :returns: None
+ """
if not relative.endswith('/'):
relative += '/'
name = self.data + relative
diff --git a/src/lib/Bcfg2/Server/Plugin/interfaces.py b/src/lib/Bcfg2/Server/Plugin/interfaces.py
index a6543e9b9..626f82a2d 100644
--- a/src/lib/Bcfg2/Server/Plugin/interfaces.py
+++ b/src/lib/Bcfg2/Server/Plugin/interfaces.py
@@ -26,9 +26,8 @@ class Generator(object):
client metadata object the entry is being generated for.
#. If the entry is not listed in ``Entries``, the Bcfg2 core calls
- :func:`Bcfg2.Server.Plugin.Generator.HandlesEntry`; if that
- returns True, then it calls
- :func:`Bcfg2.Server.Plugin.Generator.HandleEntry`.
+ :func:`HandlesEntry`; if that returns True, then it calls
+ :func:`HandleEntry`.
"""
def HandlesEntry(self, entry, metadata):
@@ -42,7 +41,7 @@ class Generator(object):
:param metadata: The client metadata
:type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata
:return: bool - Whether or not this plugin can handle the entry
- :raises: Bcfg2.Server.Plugin.PluginExecutionError
+ :raises: :class:`Bcfg2.Server.Plugin.exceptions.PluginExecutionError`
"""
return False
@@ -50,7 +49,7 @@ class Generator(object):
""" HandlesEntry is the slow path method for binding
configuration binding requests. It is called if the
``Entries`` dict does not contain a method for binding the
- entry, and :func:`Bcfg2.Server.Plugin.Generator.HandlesEntry`
+ entry, and :func:`HandlesEntry`
returns True.
:param entry: The entry to bind
@@ -58,7 +57,7 @@ class Generator(object):
:param metadata: The client metadata
:type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata
:return: lxml.etree._Element - The fully bound entry
- :raises: Bcfg2.Server.Plugin.PluginExecutionError
+ :raises: :class:`Bcfg2.Server.Plugin.exceptions.PluginExecutionError`
"""
return entry
@@ -112,8 +111,8 @@ class Metadata(object):
:param profile: Client Bcfg2 version
:type profile: string
:return: None
- :raises: Bcfg2.Server.Plugin.MetadataRuntimeError,
- Bcfg2.Server.Plugin.MetadataConsistencyError
+ :raises: :class:`Bcfg2.Server.Plugin.exceptions.MetadataRuntimeError`,
+ :class:`Bcfg2.Server.Plugin.exceptions.MetadataConsistencyError`
"""
pass
@@ -128,8 +127,8 @@ class Metadata(object):
:param address: Address pair of ``(<ip address>, <hostname>)``
:type address: tuple
:return: None
- :raises: Bcfg2.Server.Plugin.MetadataRuntimeError,
- Bcfg2.Server.Plugin.MetadataConsistencyError
+ :raises: :class:`Bcfg2.Server.Plugin.exceptions.MetadataRuntimeError`,
+ :class:`Bcfg2.Server.Plugin.exceptions.MetadataConsistencyError`
"""
pass
@@ -146,8 +145,8 @@ class Metadata(object):
entire client hostname resolution class
:type cleanup_cache: bool
:return: string - canonical client hostname
- :raises: Bcfg2.Server.Plugin.MetadataRuntimeError,
- Bcfg2.Server.Plugin.MetadataConsistencyError
+ :raises: :class:`Bcfg2.Server.Plugin.exceptions.MetadataRuntimeError`,
+ :class:`Bcfg2.Server.Plugin.exceptions.MetadataConsistencyError`
"""
return address[1]
@@ -181,7 +180,7 @@ class Metadata(object):
def merge_additional_data(self, imd, source, data):
""" Add arbitrary data from a
- :class:`Bcfg2.Server.Plugin.Connector` plugin to the given
+ :class:`Connector` plugin to the given
metadata object.
:param imd: An initial metadata object
@@ -196,7 +195,7 @@ class Metadata(object):
def merge_additional_groups(self, imd, groups):
""" Add groups from a
- :class:`Bcfg2.Server.Plugin.Connector` plugin to the given
+ :class:`Connector` plugin to the given
metadata object.
:param imd: An initial metadata object
@@ -278,7 +277,7 @@ class Probing(object):
class Statistics(Plugin):
""" Statistics plugins handle statistics for clients. In general,
you should avoid using Statistics and use
- :class:`Bcfg2.Server.Plugin.ThreadedStatistics` instead."""
+ :class:`ThreadedStatistics` instead."""
def process_statistics(self, client, xdata):
""" Process the given XML statistics data for the specified
@@ -411,9 +410,9 @@ class ThreadedStatistics(Statistics, threading.Thread):
def handle_statistic(self, metadata, data):
""" Process the given XML statistics data for the specified
client object. This differs from the
- :func:`Bcfg2.Server.Plugin.Statistics.process_statistics`
- method only in that ThreadedStatistics first adds the data to
- a queue, and then processes them in a separate thread.
+ :func:`Statistics.process_statistics` method only in that
+ ThreadedStatistics first adds the data to a queue, and then
+ processes them in a separate thread.
:param metadata: The client metadata
:type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata
@@ -474,7 +473,7 @@ class StructureValidator(object):
describing the structures for this client
:type config: list
:returns: None
- :raises: Bcfg2.Server.Plugin.ValidationError
+ :raises: :class:`Bcfg2.Server.Plugin.exceptions.ValidationError`
"""
raise NotImplementedError
@@ -493,7 +492,7 @@ class GoalValidator(object):
:param config: The full configuration for the client
:type config: lxml.etree._Element
:returns: None
- :raises: Bcfg2.Server.Plugin.ValidationError
+ :raises: :class:`Bcfg2.Server.Plugin.exceptions:ValidationError`
"""
raise NotImplementedError
@@ -529,8 +528,8 @@ class ClientRunHooks(object):
def end_client_run(self, metadata):
""" Invoked at the end of a client run, immediately after
- :class:`Bcfg2.Server.Plugin.GoalValidator` plugins have been run
- and just before the configuration is returned to the client.
+ :class:`GoalValidator` plugins have been run and just before
+ the configuration is returned to the client.
:param metadata: The client metadata object
:type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata