Source code for watchmaker

# -*- coding: utf-8 -*-
"""Watchmaker module."""
from __future__ import (
    absolute_import,
    division,
    print_function,
    unicode_literals,
    with_statement,
)

import datetime
import logging
import os
import platform
import re
import subprocess

import importlib_metadata
import oschmod
import yaml

import watchmaker.utils
from watchmaker.config import get_configs
from watchmaker.exceptions import InvalidValueError, WatchmakerError
from watchmaker.logger import log_system_details
from watchmaker.managers.worker_manager import (
    LinuxWorkersManager,
    WindowsWorkersManager,
)
from watchmaker.status import Status


def _extract_version(package_name):
    return importlib_metadata.version(package_name)


def _version_info(app_name, version):
    return "{0}/{1} Python/{2} {3}/{4}".format(
        app_name,
        version,
        platform.python_version(),
        platform.system(),
        platform.release(),
    )


__version__ = _extract_version("watchmaker")
VERSION_INFO = _version_info("Watchmaker", __version__)


[docs] class Arguments(dict): """ Create an arguments object for the :class:`watchmaker.Client`. Args: config_path: (:obj:`str`) Path or URL to the Watchmaker configuration file. If ``None``, the default config.yaml file is used. (*Default*: ``None``) log_dir: (:obj:`str`) Path to a directory. If set, Watchmaker logs to a file named ``watchmaker.log`` in the specified directory. Both the directory and the file will be created if necessary. If the file already exists, Watchmaker appends to it rather than overwriting it. If this argument evaluates to ``False``, then logging to a file is disabled. Watchmaker will always output to stdout/stderr. Additionaly, Watchmaker workers may use this directory to keep other log files. (*Default*: ``None``) no_reboot: (:obj:`bool`) Switch to control whether to reboot the system upon a successful execution of :func:`watchmaker.Client.install`. When this parameter is set, Watchmaker will suppress the reboot. Watchmaker automatically suppresses the reboot if it encounters an error. (*Default*: ``False``) log_level: (:obj:`str`) Level to log at. Case-insensitive. Valid options include, from least to most verbose: - ``critical`` - ``error`` - ``warning`` - ``info`` - ``debug`` .. important:: For all **Keyword Arguments**, below, the default value of ``None`` means Watchmaker will get the value from the configuration file. Be aware that ``None`` and ``'None'`` are two different values, with different meanings and effects. Keyword Arguments: admin_groups: (:obj:`str`) Set a salt grain that specifies the domain _groups_ that should have root privileges on Linux or admin privileges on Windows. Value must be a colon-separated string. On Linux, use the ``^`` to denote spaces in the group name. (*Default*: ``None``) .. code-block:: python admin_groups = "group1:group2" # (Linux only) The group names must be lowercased. Also, if # there are spaces in a group name, replace the spaces with a # '^'. admin_groups = "space^out" # (Windows only) No special capitalization nor syntax # requirements. admin_groups = "Space Out" admin_users: (:obj:`str`) Set a salt grain that specifies the domain _users_ that should have root privileges on Linux or admin privileges on Windows. Value must be a colon-separated string. (*Default*: ``None``) .. code-block:: python admin_users = "user1:user2" computer_name: (:obj:`str`) Set a salt grain that specifies the computername to apply to the system. (*Default*: ``None``) environment: (:obj:`str`) Set a salt grain that specifies the environment in which the system is being built. For example: ``dev``, ``test``, or ``prod``. (*Default*: ``None``) salt_states: (:obj:`str`) Comma-separated string of salt states to apply. A value of ``None`` will not apply any salt states. A value of ``'Highstate'`` will apply the salt highstate. (*Default*: ``None``) ou_path: (:obj:`str`) Set a salt grain that specifies the full DN of the OU where the computer account will be created when joining a domain. (*Default*: ``None``) .. code-block:: python ou_path="OU=Super Cool App,DC=example,DC=com" extra_arguments: (:obj:`list`) A list of extra arguments to be merged into the worker configurations. The list must be formed as pairs of named arguments and values. Any leading hypens in the argument name are stripped. (*Default*: ``[]``) .. code-block:: python extra_arguments=['--arg1', 'value1', '--arg2', 'value2'] # This list would be converted to the following dict and merged # into the parameters passed to the worker configurations: {'arg1': 'value1', 'arg2': 'value2'} """ DEFAULT_VALUE = "WAM_NONE" def __init__( self, config_path=None, log_dir=None, no_reboot=False, log_level=None, *args, **kwargs ): super(Arguments, self).__init__(*args, **kwargs) self.config_path = config_path self.log_dir = log_dir self.no_reboot = no_reboot self.log_level = log_level self.admin_groups = watchmaker.utils.clean_none( kwargs.pop("admin_groups", None) or Arguments.DEFAULT_VALUE ) self.admin_users = watchmaker.utils.clean_none( kwargs.pop("admin_users", None) or Arguments.DEFAULT_VALUE ) self.computer_name = watchmaker.utils.clean_none( kwargs.pop("computer_name", None) or Arguments.DEFAULT_VALUE ) self.environment = watchmaker.utils.clean_none( kwargs.pop("environment", None) or Arguments.DEFAULT_VALUE ) self.salt_states = watchmaker.utils.clean_none( kwargs.pop("salt_states", None) or Arguments.DEFAULT_VALUE ) self.ou_path = watchmaker.utils.clean_none( kwargs.pop("ou_path", None) or Arguments.DEFAULT_VALUE ) # Parse extra_arguments passed as `--argument=value` into separate # tokens, ['--argument', 'value']. This way the `=` as the separator is # treated the same as the ` ` as the separator, e.g. `--argument value` extra_arguments = [] for item in kwargs.pop("extra_arguments", None) or []: match = re.match("^(?P<arg>-+.*?)=(?P<val>.*)", item) if match: # item is using '=' to separate argument name and value extra_arguments.extend( [ match.group("arg"), watchmaker.utils.clean_none( match.group("val") or Arguments.DEFAULT_VALUE ), ] ) elif item.startswith("-"): # item is the argument name extra_arguments.extend([item]) else: # item is the argument value extra_arguments.extend( [ watchmaker.utils.clean_none( item or Arguments.DEFAULT_VALUE ) ] ) self.extra_arguments = extra_arguments def __getattr__(self, attr): """Support attr-notation for getting dict contents.""" return super(Arguments, self).__getitem__(attr) def __setattr__(self, attr, value): """Support attr-notation for setting dict contents.""" super(Arguments, self).__setitem__(attr, value)
[docs] class Client(object): """ Prepare a system for setup and installation. Keyword Arguments: arguments: (:obj:`Arguments`) A dictionary of arguments. See :class:`watchmaker.Arguments`. """ def __init__(self, arguments): self.log = logging.getLogger( "{0}.{1}".format(__name__, self.__class__.__name__) ) # Pop extra_arguments now so we can log it separately extra_arguments = arguments.pop("extra_arguments", []) header = " WATCHMAKER RUN " header = header.rjust((40 + len(header) // 2), "#").ljust(80, "#") self.log.info(header) self.log.debug("Watchmaker Version: %s", __version__) self.log.debug("Parameters: %s", arguments) self.log.debug("Extra Parameters: %s", extra_arguments) # Pop remaining arguments used by watchmaker.Client itself self.no_reboot = arguments.pop("no_reboot", False) self.config_path = arguments.pop("config_path") self.log_dir = arguments.pop("log_dir") self.log_level = arguments.pop("log_level") log_system_details(self.log) # Get the system params self.system = platform.system().lower() self._set_system_params() self.log.debug("System Parameters: %s", self.system_params) # All remaining arguments are worker_args self.worker_args = self._get_worker_args(arguments, extra_arguments) self.config, status_config = get_configs( self.system, self.worker_args, self.config_path ) self.status = Status(status_config) self.status.update_status("RUNNING") def _get_linux_system_params(self): """Set ``self.system_params`` attribute for Linux systems.""" params = {} params["prepdir"] = os.path.join( "{0}".format(self.system_drive), "usr", "tmp", "watchmaker" ) params["readyfile"] = os.path.join( "{0}".format(self.system_drive), "var", "run", "system-is-ready" ) params["logdir"] = os.path.join( "{0}".format(self.system_drive), "var", "log" ) params["workingdir"] = os.path.join( "{0}".format(params["prepdir"]), "workingfiles" ) params["restart"] = "shutdown -r +1 &" return params def _get_windows_system_params(self): """Set ``self.system_params`` attribute for Windows systems.""" params = {} # os.path.join does not produce path as expected when first string # ends in colon; so using a join on the sep character. params["prepdir"] = os.path.sep.join([self.system_drive, "Watchmaker"]) params["readyfile"] = os.path.join( "{0}".format(params["prepdir"]), "system-is-ready" ) params["logdir"] = os.path.join( "{0}".format(params["prepdir"]), "Logs" ) params["workingdir"] = os.path.join( "{0}".format(params["prepdir"]), "WorkingFiles" ) params["shutdown_path"] = os.path.join( "{0}".format(os.environ["SYSTEMROOT"]), "system32", "shutdown.exe" ) params["restart"] = ( params["shutdown_path"] + " /r /t 30 /d p:2:4 /c " + '"Watchmaker complete. Rebooting computer."' ) return params def _set_system_params(self): """Set OS-specific attributes.""" if "linux" in self.system: self.system_drive = "/" self.workers_manager = LinuxWorkersManager self.system_params = self._get_linux_system_params() os.umask(0o077) elif "windows" in self.system: self.system_drive = os.environ["SYSTEMDRIVE"] self.workers_manager = WindowsWorkersManager self.system_params = self._get_windows_system_params() else: msg = "System, {0}, is not recognized?".format(self.system) self.log.critical(msg) raise WatchmakerError(msg) if self.log_dir: self.system_params["logdir"] = self.log_dir def _get_worker_args(self, arguments, extra_arguments): worker_args = arguments # Convert extra_arguments to a dict and merge it with worker_args. # Leading hypens are removed, and other hyphens are converted to # underscores worker_args.update( dict( (k.lstrip("-").replace("-", "_"), v) for k, v in zip(*[iter(extra_arguments)] * 2) ) ) try: # Set self.worker_args, removing `None` values from worker_args return dict( (k, yaml.safe_load("null" if v is None else v)) for k, v in worker_args.items() if v != Arguments.DEFAULT_VALUE ) except yaml.YAMLError as exc: if hasattr(exc, "problem_mark"): msg = ( "Failed to parse argument value as YAML. Check the format " "and/or properly quote the value when using the CLI to " "account for shell interpolation. YAML error: {0}" ).format(str(exc)) self.log.critical(msg) raise InvalidValueError(msg) raise
[docs] def install(self): """ Execute the watchmaker workers against the system. Upon successful execution, the system will be properly provisioned, according to the defined configuration and workers. """ self.log.info("Start time: %s", datetime.datetime.now()) self.log.info("Workers to execute: %s", self.config.keys()) # Create watchmaker directories try: os.makedirs(self.system_params["workingdir"]) oschmod.set_mode(self.system_params["prepdir"], 0o700) except OSError: if not os.path.exists(self.system_params["workingdir"]): msg = "Unable to create directory - {0}".format( self.system_params["workingdir"] ) self.log.critical(msg) self.status.update_status("ERROR") raise workers_manager = self.workers_manager( system_params=self.system_params, workers=self.config ) try: workers_manager.worker_cadence() except Exception: msg = "Execution of the workers cadence has failed." self.log.critical(msg) self.status.update_status("ERROR") raise if self.no_reboot: self.log.info( "Detected `no-reboot` switch. System will not be rebooted." ) else: self.log.info( "Reboot scheduled. System will reboot after the script exits." ) subprocess.call(self.system_params["restart"], shell=True) self.status.update_status("COMPLETE") self.log.info("Stop time: %s", datetime.datetime.now())