Source code for Bcfg2.Client.Tools

"""This contains all Bcfg2 Tool modules"""

import os
import sys
import stat
import logging
import Bcfg2.Options
import Bcfg2.Client
import Bcfg2.Client.XML
from Bcfg2.Utils import Executor, ClassName


[docs]class ToolInstantiationError(Exception): """ This error is raised if the toolset cannot be instantiated. """ pass
[docs]class Tool(object): """ The base tool class. All tools subclass this. .. private-include: _entry_is_complete .. autoattribute:: Bcfg2.Client.Tools.Tool.__execs__ .. autoattribute:: Bcfg2.Client.Tools.Tool.__handles__ .. autoattribute:: Bcfg2.Client.Tools.Tool.__req__ .. autoattribute:: Bcfg2.Client.Tools.Tool.__important__ """ options = [ Bcfg2.Options.Option( cf=('client', 'command_timeout'), help="Timeout when running external commands other than probes", type=Bcfg2.Options.Types.timeout)] #: The name of the tool. By default this uses #: :class:`Bcfg2.Client.Tools.ClassName` to ensure that it is the #: same as the name of the class. name = ClassName() #: Full paths to all executables the tool uses. When the tool is #: instantiated it will check to ensure that all of these files #: exist and are executable. __execs__ = [] #: A list of 2-tuples of entries handled by this tool. Each #: 2-tuple should contain ``(<tag>, <type>)``, where ``<type>`` is #: the ``type`` attribute of the entry. If this tool handles #: entries with no ``type`` attribute, specify None. __handles__ = [] #: A dict that describes the required attributes for entries #: handled by this tool. The keys are the names of tags. The #: values may either be lists of attribute names (if the same #: attributes are required by all tags of that name), or dicts #: whose keys are the ``type`` attribute and whose values are #: lists of attributes required by tags with that ``type`` #: attribute. In that case, the ``type`` attribute will also be #: required. __req__ = {} #: A list of entry names that will be treated as important and #: installed before other entries. __important__ = [] #: This tool is deprecated, and a warning will be produced if it #: is used. deprecated = False #: This tool is experimental, and a warning will be produced if it #: is used. experimental = False #: List of other tools (by name) that this tool conflicts with. #: If any of the listed tools are loaded, they will be removed at #: runtime with a warning. conflicts = [] def __init__(self, config): """ :param config: The XML configuration for this client :type config: lxml.etree._Element :raises: :exc:`Bcfg2.Client.Tools.ToolInstantiationError` """ #: A :class:`logging.Logger` object that will be used by this #: tool for logging self.logger = logging.getLogger(self.name) #: The XML configuration for this client self.config = config #: An :class:`Bcfg2.Utils.Executor` object for #: running external commands. self.cmd = Executor(timeout=Bcfg2.Options.setup.command_timeout) #: A list of entries that have been modified by this tool self.modified = [] #: A list of extra entries that are not listed in the #: configuration self.extra = [] #: A list of all entries handled by this tool self.handled = [] self._analyze_config() self._check_execs() def _analyze_config(self): """ Analyze the config at tool initialization-time for important and handled entries """ for struct in self.config: for entry in struct: if (entry.tag == 'Path' and entry.get('important', 'false').lower() == 'true'): self.__important__.append(entry.get('name')) self.handled = self.getSupportedEntries() def _check_execs(self): """ Check all executables used by this tool to ensure that they exist and are executable """ for filename in self.__execs__: try: mode = stat.S_IMODE(os.stat(filename)[stat.ST_MODE]) except OSError: raise ToolInstantiationError(sys.exc_info()[1]) except: raise ToolInstantiationError("%s: Failed to stat %s" % (self.name, filename)) if not mode & stat.S_IEXEC: raise ToolInstantiationError("%s: %s not executable" % (self.name, filename)) def _install_allowed(self, entry): """ Return true if the given entry is allowed to be installed by the whitelist or blacklist """ if (Bcfg2.Options.setup.decision == 'whitelist' and not Bcfg2.Client.matches_white_list( entry, Bcfg2.Options.setup.decision_list)): self.logger.info("In whitelist mode: suppressing Action: %s" % entry.get('name')) return False if (Bcfg2.Options.setup.decision == 'blacklist' and not Bcfg2.Client.passes_black_list( entry, Bcfg2.Options.setup.decision_list)): self.logger.info("In blacklist mode: suppressing Action: %s" % entry.get('name')) return False return True
[docs] def BundleUpdated(self, bundle): # pylint: disable=W0613 """ Callback that is invoked when a bundle has been updated. :param bundle: The bundle that has been updated :type bundle: lxml.etree._Element :returns: dict - A dict of the state of entries suitable for updating :attr:`Bcfg2.Client.Client.states` """ return dict()
[docs] def BundleNotUpdated(self, bundle): # pylint: disable=W0613 """ Callback that is invoked when a bundle has been updated. :param bundle: The bundle that has been updated :type bundle: lxml.etree._Element :returns: dict - A dict of the state of entries suitable for updating :attr:`Bcfg2.Client.Client.states` """ return dict()
[docs] def Inventory(self, structures=None): """ Take an inventory of the system as it exists. This involves two steps: * Call the appropriate entry-specific Verify method for each entry this tool verifies; * Call :func:`Bcfg2.Client.Tools.Tool.FindExtra` to populate :attr:`Bcfg2.Client.Tools.Tool.extra` with extra entries. This implementation of :func:`Bcfg2.Client.Tools.Tool.Inventory` calls a ``Verify<tag>`` method to verify each entry, where ``<tag>`` is the entry tag. E.g., a Path entry would be verified by calling :func:`VerifyPath`. :param structures: The list of structures (i.e., bundles) to get entries from. If this is not given, all children of :attr:`Bcfg2.Client.Tools.Tool.config` will be used. :type structures: list of lxml.etree._Element :returns: dict - A dict of the state of entries suitable for updating :attr:`Bcfg2.Client.Client.states` """ if not structures: structures = self.config.getchildren() mods = self.buildModlist() states = dict() for struct in structures: for entry in struct.getchildren(): if self.canVerify(entry): try: func = getattr(self, "Verify%s" % entry.tag) except AttributeError: self.logger.error("%s: Cannot verify %s entries" % (self.name, entry.tag)) continue try: states[entry] = func(entry, mods) except KeyboardInterrupt: raise except: # pylint: disable=W0702 self.logger.error("%s: Unexpected failure verifying %s" % (self.name, self.primarykey(entry)), exc_info=1) self.extra = self.FindExtra() return states
[docs] def Install(self, entries): """ Install entries. 'Install' in this sense means either initially install, or update as necessary to match the specification. This implementation of :func:`Bcfg2.Client.Tools.Tool.Install` calls a ``Install<tag>`` method to install each entry, where ``<tag>`` is the entry tag. E.g., a Path entry would be installed by calling :func:`InstallPath`. :param entries: The entries to install :type entries: list of lxml.etree._Element :returns: dict - A dict of the state of entries suitable for updating :attr:`Bcfg2.Client.Client.states` """ states = dict() for entry in entries: try: func = getattr(self, "Install%s" % entry.tag) except AttributeError: self.logger.error("%s: Cannot install %s entries" % (self.name, entry.tag)) continue try: states[entry] = func(entry) if states[entry]: self.modified.append(entry) except: # pylint: disable=W0702 self.logger.error("%s: Unexpected failure installing %s" % (self.name, self.primarykey(entry)), exc_info=1) return states
[docs] def Remove(self, entries): """ Remove specified extra entries. :param entries: The entries to remove :type entries: list of lxml.etree._Element :returns: None """ pass
[docs] def getSupportedEntries(self): """ Get all entries that are handled by this tool. :returns: list of lxml.etree._Element """ rv = [] for struct in self.config.getchildren(): rv.extend([entry for entry in struct.getchildren() if self.handlesEntry(entry)]) return rv
[docs] def handlesEntry(self, entry): """ Return True if the entry is handled by this tool. :param entry: Determine if this entry is handled. :type entry: lxml.etree._Element :returns: bool """ return (entry.tag, entry.get('type')) in self.__handles__
[docs] def buildModlist(self): """ Build a list of all Path entries in the configuration. (This can be used to determine which paths might be modified from their original state, useful for verifying packages) :returns: list of lxml.etree._Element """ rv = [] for struct in self.config.getchildren(): rv.extend([entry.get('name') for entry in struct.getchildren() if entry.tag == 'Path']) return rv
[docs] def missing_attrs(self, entry): """ Return a list of attributes that were expected on an entry (from :attr:`Bcfg2.Client.Tools.Tool.__req__`), but not found. :param entry: The entry to find missing attributes on :type entry: lxml.etree._Element :returns: list of strings """ required = self.__req__[entry.tag] if isinstance(required, dict): required = ["type"] try: required.extend(self.__req__[entry.tag][entry.get("type")]) except KeyError: pass return [attr for attr in required if attr not in entry.attrib or not entry.attrib[attr]]
[docs] def canVerify(self, entry): """ Test if entry can be verified by calling :func:`Bcfg2.Client.Tools.Tool._entry_is_complete`. :param entry: The entry to evaluate :type entry: lxml.etree._Element :returns: bool - True if the entry can be verified, False otherwise. """ return self._entry_is_complete(entry, action="verify")
[docs] def FindExtra(self): """ Return a list of extra entries, i.e., entries that exist on the client but are not in the configuration. :returns: list of lxml.etree._Element """ return []
[docs] def primarykey(self, entry): """ Return a string that describes the entry uniquely amongst all entries in the configuration. :param entry: The entry to describe :type entry: lxml.etree._Element :returns: string """ return "%s:%s" % (entry.tag, entry.get("name"))
[docs] def canInstall(self, entry): """ Test if entry can be installed by calling :func:`Bcfg2.Client.Tools.Tool._entry_is_complete`. :param entry: The entry to evaluate :type entry: lxml.etree._Element :returns: bool - True if the entry can be installed, False otherwise. """ return self._entry_is_complete(entry, action="install")
[docs] def _entry_is_complete(self, entry, action=None): """ Test if the entry is complete. This involves three things: * The entry is handled by this tool (as reported by :func:`Bcfg2.Client.Tools.Tool.handlesEntry`; * The entry does not report a bind failure; * The entry is not missing any attributes (as reported by :func:`Bcfg2.Client.Tools.Tool.missing_attrs`). :param entry: The entry to evaluate :type entry: lxml.etree._Element :param action: The action being performed on the entry (e.g., "install", "verify"). This is used to produce error messages; if not provided, generic error messages will be used. :type action: string :returns: bool - True if the entry can be verified, False otherwise. """ if not self.handlesEntry(entry): return False if 'failure' in entry.attrib: if action is None: msg = "%s: %s reports bind failure" else: msg = "%%s: Cannot %s entry %%s with bind failure" % action self.logger.error(msg % (self.name, self.primarykey(entry))) return False missing = self.missing_attrs(entry) if missing: if action is None: desc = "%s is" % self.primarykey(entry) else: desc = "Cannot %s %s due to" % (action, self.primarykey(entry)) self.logger.error("%s: %s missing required attribute(s): %s" % (self.name, desc, ", ".join(missing))) return False return True
[docs]class PkgTool(Tool): """ PkgTool provides a one-pass install with fallback for use with packaging systems. PkgTool makes a number of assumptions that may need to be overridden by a subclass. For instance, it assumes that packages are installed by a shell command; that only one version of a given package can be installed; etc. Nonetheless, it offers a strong base for writing simple package tools. """ #: A tuple describing the format of the command to run to install #: a single package. The first element of the tuple is a string #: giving the format of the command, with a single '%s' for the #: name of the package or packages to be installed. The second #: element is a tuple whose first element is the format of the #: name of the package, and whose second element is a list whose #: members are the names of attributes that will be used when #: formatting the package name format string. pkgtool = ('echo %s', ('%s', ['name'])) #: The ``type`` attribute of Packages handled by this tool. pkgtype = 'echo' def __init__(self, config): Tool.__init__(self, config) #: A dict of installed packages; the keys should be package #: names and the values should be simple strings giving the #: installed version. self.installed = {} self.RefreshPackages()
[docs] def VerifyPackage(self, entry, modlist): """ Verify the given Package entry. :param entry: The Package entry to verify :type entry: lxml.etree._Element :param modlist: A list of all Path entries in the configuration, which may be considered when verifying a package. For instance, a package should verify successfully if paths in ``modlist`` have been modified outside the package. :type modlist: list of strings :returns: bool - True if the package verifies, false otherwise. """ raise NotImplementedError
def _get_package_command(self, packages): """ Get the command to install the given list of packages. :param packages: The Package entries to install :type packages: list of lxml.etree._Element :returns: string - the command to run """ pkgargs = " ".join(self.pkgtool[1][0] % tuple(pkg.get(field) for field in self.pkgtool[1][1]) for pkg in packages) return self.pkgtool[0] % pkgargs
[docs] def Install(self, packages): """ Run a one-pass install where all required packages are installed with a single command, followed by single package installs in case of failure. :param entries: The entries to install :type entries: list of lxml.etree._Element :returns: dict - A dict of the state of entries suitable for updating :attr:`Bcfg2.Client.Client.states` """ self.logger.info("Trying single pass package install for pkgtype %s" % self.pkgtype) states = dict() if self.cmd.run(self._get_package_command(packages)): self.logger.info("Single Pass Succeded") # set all package states to true and flush workqueues for entry in packages: self.logger.debug('Setting state to true for %s' % self.primarykey(entry)) states[entry] = True self.RefreshPackages() else: self.logger.error("Single Pass Failed") # do single pass installs self.RefreshPackages() for pkg in packages: # handle state tracking updates if self.VerifyPackage(pkg, []): self.logger.info("Forcing state to true for pkg %s" % (pkg.get('name'))) states[pkg] = True else: self.logger.info("Installing pkg %s version %s" % (pkg.get('name'), pkg.get('version'))) if self.cmd.run(self._get_package_command([pkg])): states[pkg] = True else: states[pkg] = False self.logger.error("Failed to install package %s" % pkg.get('name')) self.RefreshPackages() self.modified.extend(entry for entry in packages if entry in states and states[entry]) return states
[docs] def RefreshPackages(self): """ Refresh the internal representation of the package database (:attr:`Bcfg2.Client.Tools.PkgTool.installed`). :returns: None""" raise NotImplementedError
[docs] def FindExtra(self): packages = [entry.get('name') for entry in self.getSupportedEntries()] extras = [data for data in list(self.installed.items()) if data[0] not in packages] return [Bcfg2.Client.XML.Element('Package', name=name, type=self.pkgtype, current_version=version) for (name, version) in extras]
FindExtra.__doc__ = Tool.FindExtra.__doc__
[docs]class SvcTool(Tool): """ Base class for tools that handle Service entries """ options = Tool.options + [ Bcfg2.Options.Option( '-s', '--service-mode', default='default', choices=['default', 'disabled', 'build'], help='Set client service mode')] def __init__(self, config): Tool.__init__(self, config) #: List of services that have been restarted self.restarted = [] __init__.__doc__ = Tool.__init__.__doc__
[docs] def get_svc_command(self, service, action): """ Return a command that can be run to start or stop a service. :param service: The service entry to modify :type service: lxml.etree._Element :param action: The action to take (e.g., "stop", "start") :type action: string :returns: string - The command to run """ return '/etc/init.d/%s %s' % (service.get('name'), action)
[docs] def get_bootstatus(self, service): """ Return the bootstatus attribute if it exists. :param service: The service entry :type service: lxml.etree._Element :returns: string or None - Value of bootstatus if it exists. If bootstatus is unspecified and status is not *ignore*, return value of status. If bootstatus is unspecified and status is *ignore*, return None. """ if service.get('bootstatus') is not None: return service.get('bootstatus') elif service.get('status') != 'ignore': return service.get('status') return None
[docs] def start_service(self, service): """ Start a service. :param service: The service entry to modify :type service: lxml.etree._Element :returns: Bcfg2.Utils.ExecutorResult - The return value from :class:`Bcfg2.Utils.Executor.run` """ self.logger.debug('Starting service %s' % service.get('name')) return self.cmd.run(self.get_svc_command(service, 'start'))
[docs] def stop_service(self, service): """ Stop a service. :param service: The service entry to modify :type service: lxml.etree._Element :returns: Bcfg2.Utils.ExecutorResult - The return value from :class:`Bcfg2.Utils.Executor.run` """ self.logger.debug('Stopping service %s' % service.get('name')) return self.cmd.run(self.get_svc_command(service, 'stop'))
[docs] def restart_service(self, service): """Restart a service. :param service: The service entry to modify :type service: lxml.etree._Element :returns: Bcfg2.Utils.ExecutorResult - The return value from :class:`Bcfg2.Utils.Executor.run` """ self.logger.debug('Restarting service %s' % service.get('name')) restart_target = service.get('target', 'restart') return self.cmd.run(self.get_svc_command(service, restart_target))
[docs] def check_service(self, service): """ Check the status a service. :param service: The service entry to modify :type service: lxml.etree._Element :returns: bool - True if the status command returned 0, False otherwise """ return bool(self.cmd.run(self.get_svc_command(service, 'status')))
[docs] def Remove(self, services): if Bcfg2.Options.setup.service_mode != 'disabled': for entry in services: entry.set("status", "off") self.InstallService(entry)
Remove.__doc__ = Tool.Remove.__doc__
[docs] def BundleUpdated(self, bundle): if Bcfg2.Options.setup.service_mode == 'disabled': return for entry in bundle: if (not self.handlesEntry(entry) or not self._install_allowed(entry)): continue estatus = entry.get('status') restart = entry.get("restart", "true").lower() if (restart == "false" or estatus == 'ignore' or (restart == "interactive" and not Bcfg2.Options.setup.interactive)): continue success = False if estatus == 'on': if Bcfg2.Options.setup.service_mode == 'build': success = self.stop_service(entry) elif entry.get('name') not in self.restarted: if Bcfg2.Options.setup.interactive: if not Bcfg2.Client.prompt('Restart service %s? (y/N) ' % entry.get('name')): continue success = self.restart_service(entry) if success: self.restarted.append(entry.get('name')) else: success = self.stop_service(entry) if not success: self.logger.error("Failed to manipulate service %s" % (entry.get('name'))) return dict()
BundleUpdated.__doc__ = Tool.BundleUpdated.__doc__
[docs] def Install(self, entries): install_entries = [] for entry in entries: if entry.get('install', 'true').lower() == 'false': self.logger.info("Installation is false for %s:%s, skipping" % (entry.tag, entry.get('name'))) else: install_entries.append(entry) return Tool.Install(self, install_entries)
Install.__doc__ = Tool.Install.__doc__
[docs] def InstallService(self, entry): """ Install a single service entry. See :func:`Bcfg2.Client.Tools.Tool.Install`. :param entry: The Service entry to install :type entry: lxml.etree._Element :returns: bool - True if installation was successful, False otherwise """ raise NotImplementedError