From 8088d120d229dd39e105fa6d0b3416d90775faae Mon Sep 17 00:00:00 2001 From: Frank Brehm Date: Fri, 26 Nov 2021 10:46:37 +0100 Subject: [PATCH] Importing Python stuff from git@git.pixelpark.com:frabrehm/admin-tools.git --- bin/dns-deploy-zones | 0 lib/pp_admintools/__init__.py | 6 + lib/pp_admintools/cfg_app.py | 938 ++++++++++++++++++++ lib/pp_admintools/deploy_zones_from_pdns.py | 884 ++++++++++++++++++ lib/pp_admintools/errors.py | 36 + lib/pp_admintools/global_version.py | 15 + lib/pp_admintools/mailaddress.py | 279 ++++++ lib/pp_admintools/merge.py | 75 ++ lib/pp_admintools/pdns_app.py | 857 ++++++++++++++++++ lib/pp_admintools/pidfile.py | 523 +++++++++++ 10 files changed, 3613 insertions(+) mode change 100644 => 100755 bin/dns-deploy-zones create mode 100644 lib/pp_admintools/__init__.py create mode 100644 lib/pp_admintools/cfg_app.py create mode 100644 lib/pp_admintools/deploy_zones_from_pdns.py create mode 100644 lib/pp_admintools/errors.py create mode 100644 lib/pp_admintools/global_version.py create mode 100644 lib/pp_admintools/mailaddress.py create mode 100644 lib/pp_admintools/merge.py create mode 100644 lib/pp_admintools/pdns_app.py create mode 100644 lib/pp_admintools/pidfile.py diff --git a/bin/dns-deploy-zones b/bin/dns-deploy-zones old mode 100644 new mode 100755 diff --git a/lib/pp_admintools/__init__.py b/lib/pp_admintools/__init__.py new file mode 100644 index 0000000..894f795 --- /dev/null +++ b/lib/pp_admintools/__init__.py @@ -0,0 +1,6 @@ +#!/bin/env python3 +# -*- coding: utf-8 -*- + +__version__ = '0.2.0' + +# vim: ts=4 et list diff --git a/lib/pp_admintools/cfg_app.py b/lib/pp_admintools/cfg_app.py new file mode 100644 index 0000000..4b6eb4a --- /dev/null +++ b/lib/pp_admintools/cfg_app.py @@ -0,0 +1,938 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@author: Frank Brehm +@contact: frank.brehm@pixelpark.com +@copyright: © 2021 by Frank Brehm, Berlin +@summary: The module for the application object with support + for configuration files. +""" +from __future__ import absolute_import + +# Standard modules +import os +import logging +import logging.config +import re +import copy +import json +import socket +import pwd +import pipes +import codecs +import ipaddress + +from subprocess import Popen, PIPE + +from email.mime.text import MIMEText +from email import charset + +import smtplib + +# Third party modules +import six + +from six import StringIO +from six.moves import configparser + +from configparser import Error as ConfigParseError + +# Own modules +from fb_tools.app import BaseApplication +from fb_tools.common import pp, to_bool, RE_DOT_AT_END + +from .global_version import __version__ as __global_version__ + +from .errors import PpAppError + +from .merge import merge_structure + +from .mailaddress import MailAddress + +__version__ = '0.9.0' +LOG = logging.getLogger(__name__) + +VALID_MAIL_METHODS = ('smtp', 'sendmail') + + +# ============================================================================= +class PpCfgAppError(PpAppError): + """Base error class for all exceptions happened during + execution this configured application""" + + pass + + +# ============================================================================= +class PpConfigApplication(BaseApplication): + """ + Class for configured application objects. + """ + + default_mail_recipients = [ + 'frank.brehm@pixelpark.com' + ] + default_mail_cc = [ + 'thomas.kotschok@pixelpark.com', + ] + + default_reply_to = 'frank.brehm@pixelpark.com' + + default_mail_server = 'mx.pixelpark.net' + + current_user_name = pwd.getpwuid(os.getuid()).pw_name + current_user_gecos = pwd.getpwuid(os.getuid()).pw_gecos + default_mail_from = MailAddress(current_user_name, socket.getfqdn()) + + whitespace_re = re.compile(r'(?:[,;]+|\s*[,;]*\s+)+') + + charset.add_charset('utf-8', charset.SHORTEST, charset.QP) + + # ------------------------------------------------------------------------- + def __init__( + self, appname=None, verbose=0, version=__version__, base_dir=None, + initialized=None, usage=None, description=None, + argparse_epilog=None, argparse_prefix_chars='-', env_prefix=None, + cfg_dir=None, cfg_stems=None, cfg_encoding='utf-8', need_config_file=False): + + self.cfg_encoding = cfg_encoding + self._need_config_file = bool(need_config_file) + + self.cfg = {} + + self._cfg_dir = None + self.cfg_stems = [] + self.cfg_files = [] + self.log_cfg_files = [] + + self.mail_recipients = copy.copy(self.default_mail_recipients) + self.mail_from = '{n} <{m}>'.format( + n=self.current_user_gecos, m=self.default_mail_from) + self.mail_cc = copy.copy(self.default_mail_cc) + self.reply_to = self.default_reply_to + self.mail_method = 'smtp' + self.mail_server = self.default_mail_server + self.smtp_port = 25 + self._config_has_errors = None + + super(PpConfigApplication, self).__init__( + appname=appname, verbose=verbose, version=version, base_dir=base_dir, + initialized=False, usage=usage, description=description, + argparse_epilog=argparse_epilog, argparse_prefix_chars=argparse_prefix_chars, + env_prefix=env_prefix, + ) + + if cfg_dir is None: + self._cfg_dir = 'pixelpark' + else: + d = str(cfg_dir).strip() + if d == '': + self._cfg_dir = None + else: + self._cfg_dir = d + + if cfg_stems: + if isinstance(cfg_stems, list): + for stem in cfg_stems: + s = str(stem).strip() + if not s: + msg = "Invalid configuration stem {!r} given.".format(stem) + raise PpCfgAppError(msg) + self.cfg_stems.append(s) + else: + s = str(cfg_stems).strip() + if not s: + msg = "Invalid configuration stem {!r} given.".format(cfg_stems) + raise PpCfgAppError(msg) + self.cfg_stems.append(s) + else: + self.cfg_stems = self.appname + + self._init_cfgfiles() + + enc = getattr(self.args, 'cfg_encoding', None) + if enc: + self.cfg_encoding = enc + + self.perform_arg_parser() + self.init_logging() + + self._read_config() + self._perform_config() + + self._init_log_cfgfiles() + self.reinit_logging() + + # ----------------------------------------------------------- + @property + def need_config_file(self): + """ + hide command line parameter --default-config and + don't execute generation of default config + """ + return getattr(self, '_need_config_file', False) + + # ----------------------------------------------------------- + @property + def cfg_encoding(self): + """The encoding character set of the configuration files.""" + return self._cfg_encoding + + @cfg_encoding.setter + def cfg_encoding(self, value): + try: + codec = codecs.lookup(value) + except Exception as e: + msg = "{c} on setting encoding {v!r}: {e}".format( + c=e.__class__.__name__, v=value, e=e) + LOG.error(msg) + else: + self._cfg_encoding = codec.name + + # ----------------------------------------------------------- + @property + def config_has_errors(self): + """A flag, showing, that there are errors in configuration.""" + return self._config_has_errors + + @config_has_errors.setter + def config_has_errors(self, value): + if value is None: + self._config_has_errors = None + else: + self._config_has_errors = to_bool(value) + + # ----------------------------------------------------------- + @property + def cfg_dir(self): + """The directory containing the configuration files.""" + return self._cfg_dir + + # ------------------------------------------------------------------------- + def as_dict(self, short=True): + """ + Transforms the elements of the object into a dict + + @param short: don't include local properties in resulting dict. + @type short: bool + + @return: structure as dict + @rtype: dict + """ + + res = super(PpConfigApplication, self).as_dict(short=short) + res['need_config_file'] = self.need_config_file + res['cfg_encoding'] = self.cfg_encoding + res['cfg_dir'] = self.cfg_dir + res['config_has_errors'] = self.config_has_errors + + return res + + # ------------------------------------------------------------------------- + def init_arg_parser(self): + """ + Method to initiate the argument parser. + + This method should be explicitely called by all init_arg_parser() + methods in descendant classes. + """ + + mail_group = self.arg_parser.add_argument_group('Mailing options') + + mail_group.add_argument( + '--recipients', '--mail-recipients', + metavar="ADDRESS", nargs='+', dest="mail_recipients", + help="Mail addresses of all recipients for mails generated by this script." + ) + + mail_group.add_argument( + '--cc', '--mail-cc', + metavar="ADDRESS", nargs='*', dest="mail_cc", + help="Mail addresses of all CC recipients for mails generated by this script." + ) + + mail_group.add_argument( + '--reply-to', '--mail-reply-to', + metavar="ADDRESS", dest="mail_reply_to", + help="Reply mail address for mails generated by this script." + ) + + mail_group.add_argument( + '--mail-method', + metavar="METHOD", choices=VALID_MAIL_METHODS, dest="mail_method", + help=( + "Method for sending the mails generated by this script. " + "Valid values: {v}, default: {d!r}.".format( + v=', '.join(map(lambda x: repr(x), VALID_MAIL_METHODS)), + d=self.mail_method)) + ) + + mail_group.add_argument( + '--mail-server', + metavar="SERVER", dest="mail_server", + help=( + "Mail server for submitting generated by this script if " + "the mail method of this script is 'smtp'. Default: {!r}.").format( + self.mail_server) + ) + + mail_group.add_argument( + '--smtp-port', + metavar="PORT", type=int, dest='smtp_port', + help=( + "The port to use for submitting generated by this script if " + "the mail method of this script is 'smtp'. Default: {}.".format(self.smtp_port)) + ) + + cfgfile_group = self.arg_parser.add_argument_group('Config file options') + + cfgfile_group.add_argument( + "-C", "--cfgfile", "--cfg-file", "--config", + metavar="FILE", nargs='+', dest="cfg_file", + help="Configuration files to use additional to the standard configuration files.", + ) + + cfgfile_group.add_argument( + "--log-cfgfile", + metavar="FILE", dest="log_cfgfile", + help=( + "Configuration file for logging in JSON format. " + "See https://docs.python.org/3/library/logging.config.html" + "#logging-config-dictschema how the structures has to be defined.") + ) + + cfgfile_group.add_argument( + "--cfg-encoding", + metavar="ENCODING", dest="cfg_encoding", default=self.cfg_encoding, + help=( + "The encoding character set of the configuration files " + "(default: %(default)r)."), + ) + + # ------------------------------------------------------------------------- + def _init_cfgfiles(self): + """Method to generate the self.cfg_files list.""" + + self.cfg_files = [] + + cfg_basenames = [] + for stem in self.cfg_stems: + cfg_basename = '{}.ini'.format(stem) + cfg_basenames.append(cfg_basename) + + # add /etc/app/app.ini or $VIRTUAL_ENV/etc/app/app.ini + etc_dir = os.sep + 'etc' + if 'VIRTUAL_ENV' in os.environ: + etc_dir = os.path.join(os.environ['VIRTUAL_ENV'], 'etc') + for cfg_basename in cfg_basenames: + syscfg_fn = None + if self.cfg_dir: + syscfg_fn = os.path.join(etc_dir, self.cfg_dir, cfg_basename) + else: + syscfg_fn = os.path.join(etc_dir, cfg_basename) + self.cfg_files.append(syscfg_fn) + + # add /etc/app.ini + mod_dir = os.path.dirname(__file__) + work_dir = os.path.abspath(os.path.join(mod_dir, '..')) + work_etc_dir = os.path.join(work_dir, 'etc') + if self.verbose > 1: + LOG.debug("Searching for {!r} ...".format(work_etc_dir)) + for cfg_basename in cfg_basenames: + self.cfg_files.append(os.path.join(work_etc_dir, cfg_basename)) + + # add $HOME/.config/app.ini + usercfg_fn = None + user_cfg_dir = os.path.expanduser('~/.config') + if user_cfg_dir: + if self.cfg_dir: + user_cfg_dir = os.path.join(user_cfg_dir, self.cfg_dir) + if self.verbose > 1: + LOG.debug("user_cfg_dir: {!r}".format(user_cfg_dir)) + for cfg_basename in cfg_basenames: + usercfg_fn = os.path.join(user_cfg_dir, cfg_basename) + self.cfg_files.append(usercfg_fn) + + # add a configfile given on command line with --cfg-file + cmdline_cfg = getattr(self.args, 'cfg_file', None) + if cmdline_cfg: + for usercfg_fn in cmdline_cfg: + self.cfg_files.append(usercfg_fn) + + # ------------------------------------------------------------------------- + def _init_log_cfgfiles(self): + """Method to generate the self.log_cfg_files list.""" + + self.log_cfg_files = [] + + cfg_basename = 'logging.json' + + # add /etc/app/logging.json or $VIRTUAL_ENV/etc/app/logging.json + etc_dir = os.sep + 'etc' + if 'VIRTUAL_ENV' in os.environ: + etc_dir = os.path.join(os.environ['VIRTUAL_ENV'], 'etc') + syscfg_fn = None + if self.cfg_dir: + syscfg_fn = os.path.join(etc_dir, self.cfg_dir, cfg_basename) + else: + syscfg_fn = os.path.join(etc_dir, cfg_basename) + self.log_cfg_files.append(syscfg_fn) + + # add /etc/app.ini + mod_dir = os.path.dirname(__file__) + work_dir = os.path.abspath(os.path.join(mod_dir, '..')) + work_etc_dir = os.path.join(work_dir, 'etc') + if self.verbose > 1: + LOG.debug("Searching for {!r} ...".format(work_etc_dir)) + self.log_cfg_files.append(os.path.join(work_etc_dir, cfg_basename)) + + # add $HOME/.config/app.ini + usercfg_fn = None + user_cfg_dir = os.path.expanduser('~/.config') + if user_cfg_dir: + if self.cfg_dir: + user_cfg_dir = os.path.join(user_cfg_dir, self.cfg_dir) + if self.verbose > 1: + LOG.debug("user_cfg_dir: {!r}".format(user_cfg_dir)) + usercfg_fn = os.path.join(user_cfg_dir, cfg_basename) + self.log_cfg_files.append(usercfg_fn) + + # add a configfile given on command line with --log-cfgfile + cmdline_cfg = getattr(self.args, 'log_cfgfile', None) + if cmdline_cfg: + self.log_cfg_files.append(cmdline_cfg) + + if self.verbose > 1: + LOG.debug("Log config files:\n{}".format(pp(self.log_cfg_files))) + + # ------------------------------------------------------------------------- + def _init_logging_from_jsonfile(self): + + open_opts = {} + if six.PY3: + open_opts['encoding'] = 'utf-8' + open_opts['errors'] = 'surrogateescape' + + found = False + for cfg_file in reversed(self.log_cfg_files): + + if self.verbose > 1: + LOG.debug("Searching for {!r} ...".format(cfg_file)) + + if not os.path.exists(cfg_file): + continue + if not os.path.isfile(cfg_file): + continue + if not os.access(cfg_file, os.R_OK): + msg = "No read access to {!r}.".format(cfg_file) + self.handle_error(msg, "File error") + continue + + log_cfg = None + if self.verbose > 1: + LOG.debug("Reading and evaluating {!r} ...".format(cfg_file)) + with open(cfg_file, 'r', **open_opts) as fh: + try: + log_cfg = json.load(fh) + except (ValueError, TypeError) as e: + msg = "Wrong file {!r} - ".format(cfg_file) + str(e) + self.handle_error(msg, e.__class__.__name__) + continue + if self.verbose: + if 'root' in log_cfg: + log_cfg['root']['level'] = 'DEBUG' + if 'handlers' in log_cfg: + for handler_name in log_cfg['handlers'].keys(): + handler = log_cfg['handlers'][handler_name] + handler['level'] = 'DEBUG' + if self.verbose > 1: + LOG.debug("Evaluated configuration from JSON:\n{} ...".format(pp(log_cfg))) + try: + logging.config.dictConfig(log_cfg) + except Exception as e: + msg = "Wrong file {!r} - ".format(cfg_file) + str(e) + self.handle_error(msg, e.__class__.__name__) + continue + found = True + break + + return found + + # ------------------------------------------------------------------------- + def reinit_logging(self): + """ + Re-Initialize the logger object. + It creates a colored loghandler with all output to STDERR. + Maybe overridden in descendant classes. + + @return: None + """ + + root_logger = logging.getLogger() + + if self._init_logging_from_jsonfile(): + if self.verbose: + root_logger.setLevel(logging.DEBUG) + return + + return + + # ------------------------------------------------------------------------- + def _read_config(self): + + if self.verbose > 2: + LOG.debug("Reading config files with character set {!r} ...".format( + self.cfg_encoding)) + self._config_has_errors = None + + open_opts = {} + if six.PY3 and self.cfg_encoding: + open_opts['encoding'] = self.cfg_encoding + open_opts['errors'] = 'surrogateescape' + + for cfg_file in self.cfg_files: + if self.verbose > 2: + LOG.debug("Searching for {!r} ...".format(cfg_file)) + if not os.path.isfile(cfg_file): + if self.verbose > 3: + LOG.debug("Config file {!r} not found.".format(cfg_file)) + continue + if self.verbose > 1: + LOG.debug("Reading {!r} ...".format(cfg_file)) + + config = configparser.ConfigParser() + try: + with open(cfg_file, 'r', **open_opts) as fh: + stream = StringIO("[default]\n" + fh.read()) + if six.PY2: + config.readfp(stream) + else: + config.read_file(stream) + except ConfigParseError as e: + msg = "Wrong configuration in {!r} found: ".format(cfg_file) + msg += str(e) + self.handle_error(msg, "Configuration error") + continue + + cfg = {} + for section in config.sections(): + if section not in cfg: + cfg[section] = {} + for (key, value) in config.items(section): + k = key.lower() + cfg[section][k] = value + if self.verbose > 2: + LOG.debug("Evaluated config from {f!r}:\n{c}".format( + f=cfg_file, c=pp(cfg))) + self.cfg = merge_structure(self.cfg, cfg) + + if self.verbose > 1: + LOG.debug("Evaluated config total:\n{}".format(pp(self.cfg))) + + # ------------------------------------------------------------------------- + def _perform_config(self): + """Execute some actions after reading the configuration.""" + + for section_name in self.cfg.keys(): + + section = self.cfg[section_name] + + if section_name.lower() == 'general': + self._perform_config_general(section, section_name) + continue + + if section_name.lower() == 'mail': + self._perform_config_mail(section, section_name) + continue + + self.perform_config() + + self._perform_mail_cmdline_options() + + if self.config_has_errors: + LOG.error("There are errors in configuration.") + self.exit(1) + else: + LOG.debug("There are no errors in configuration.") + self.config_has_errors = False + + # ------------------------------------------------------------------------- + def _perform_config_general(self, section, section_name): + + if self.verbose > 2: + LOG.debug("Evaluating config section {n!r}:\n{s}".format( + n=section_name, s=pp(section))) + + if 'verbose' in section: + v = section['verbose'] + if to_bool(v): + try: + v = int(v) + except ValueError: + v = 1 + pass + except TypeError: + v = 1 + pass + if v > self.verbose: + self.verbose = v + root_logger = logging.getLogger() + root_logger.setLevel(logging.DEBUG) + + # ------------------------------------------------------------------------- + def _perform_config_mail(self, section, section_name): + + if self.verbose > 2: + LOG.debug("Evaluating config section {n!r}:\n{s}".format( + n=section_name, s=pp(section))) + + self._perform_config_mail_rcpt(section, section_name) + self._perform_config_mail_cc(section, section_name) + self._perform_config_mail_reply_to(section, section_name) + self._perform_config_mail_method(section, section_name) + self._perform_config_mail_server(section, section_name) + self._perform_config_smtp_port(section, section_name) + + # ------------------------------------------------------------------------- + def _perform_config_mail_rcpt(self, section, section_name): + + if 'mail_recipients' not in section: + return + + v = section['mail_recipients'].strip() + self.mail_recipients = [] + if v: + tokens = self.whitespace_re.split(v) + for token in tokens: + if MailAddress.valid_address(token): + if token not in self.mail_recipients: + self.mail_recipients.append(token) + else: + msg = ( + "Found invalid recipient mail address {!r} " + "in configuration.").format( + token) + LOG.error(msg) + + # ------------------------------------------------------------------------- + def _perform_config_mail_cc(self, section, section_name): + + if 'mail_cc' not in section: + return + + v = section['mail_cc'].strip() + self.mail_cc = [] + if v: + tokens = self.whitespace_re.split(v) + if self.verbose > 1: + LOG.debug("CC addresses:\n{}".format(pp(tokens))) + for token in tokens: + if MailAddress.valid_address(token): + if token not in self.mail_cc: + self.mail_cc.append(token) + else: + msg = "Found invalid cc mail address {!r} in configuration.".format( + token) + LOG.error(msg) + + # ------------------------------------------------------------------------- + def _perform_config_mail_reply_to(self, section, section_name): + + if 'reply_to' not in section: + return + + v = section['reply_to'].strip() + self.reply_to = None + if v: + tokens = self.whitespace_re.split(v) + if len(tokens): + if MailAddress.valid_address(tokens[0]): + self.reply_to = tokens[0] + else: + msg = "Found invalid reply mail address {!r} in configuration.".format( + tokens[0]) + LOG.error(msg) + + # ------------------------------------------------------------------------- + def _perform_config_mail_method(self, section, section_name): + + if 'mail_method' not in section: + return + + v = section['mail_method'].strip().lower() + if v: + if v in VALID_MAIL_METHODS: + self.mail_method = v + else: + msg = "Found invalid mail method {!r} in configuration.".format( + section['mail_method']) + LOG.error(msg) + + # ------------------------------------------------------------------------- + def _perform_config_mail_server(self, section, section_name): + + if 'mail_server' not in section: + return + + v = section['reply_to'].strip() + if v: + self.mail_server = v + + # ------------------------------------------------------------------------- + def _perform_config_smtp_port(self, section, section_name): + + if 'smtp_port' not in section: + return + + v = section['smtp_port'] + port = self.smtp_port + try: + port = int(v) + except (ValueError, TypeError): + msg = "Found invalid SMTP port number {!r} in configuration.".format(v) + LOG.error(msg) + else: + if port <= 0: + msg = "Found invalid SMTP port number {!r} in configuration.".format(port) + LOG.error(msg) + else: + self.smtp_port = port + + # ------------------------------------------------------------------------- + def _perform_mail_cmdline_options(self): + + self._perform_cmdline_mail_rcpt() + self._perform_cmdline_mail_cc() + self._perform_cmdline_reply_to() + + v = getattr(self.args, 'mail_method', None) + if v: + self.mail_method = v + + v = getattr(self.args, 'mail_server', None) + if v: + self.mail_server = v + + v = getattr(self.args, 'smtp_port', None) + if v is not None: + if v <= 0: + msg = "Got invalid SMTP port number {!r}.".format(v) + LOG.error(msg) + else: + self.smtp_port = v + + # ------------------------------------------------------------------------- + def _perform_cmdline_mail_rcpt(self): + + v = getattr(self.args, 'mail_recipients', None) + if v is not None: + self.mail_recipients = [] + for addr in v: + tokens = self.whitespace_re.split(addr) + for token in tokens: + if MailAddress.valid_address(token): + if token not in self.mail_recipients: + self.mail_recipients.append(token) + else: + msg = "Got invalid recipient mail address {!r}.".format(token) + LOG.error(msg) + if not self.mail_recipients: + msg = "Did not found any valid recipient mail addresses." + LOG.error(msg) + + # ------------------------------------------------------------------------- + def _perform_cmdline_mail_cc(self): + + v = getattr(self.args, 'mail_cc', None) + if v is None: + return + + self.mail_cc = [] + for addr in v: + tokens = self.whitespace_re.split(addr) + for token in tokens: + if MailAddress.valid_address(token): + if token not in self.mail_cc: + self.mail_cc.append(token) + else: + msg = "Got invalid CC mail address {!r}.".format(token) + LOG.error(msg) + + # ------------------------------------------------------------------------- + def _perform_cmdline_reply_to(self): + + v = getattr(self.args, 'mail_reply_to', None) + if not v: + return + + tokens = self.whitespace_re.split(v) + if len(tokens): + if MailAddress.valid_address(tokens[0]): + self.reply_to = tokens[0] + else: + msg = "Got invalid reply mail address {!r}.".format( + tokens[0]) + LOG.error(msg) + + # ------------------------------------------------------------------------- + def perform_config(self): + """ + Execute some actions after reading the configuration. + + This method should be explicitely called by all perform_config() + methods in descendant classes. + """ + + pass + + # ------------------------------------------------------------------------- + def send_mail(self, subject, body): + + xmailer = "{a} (Admin Tools version {v})".format( + a=self.appname, v=__global_version__) + + mail = MIMEText(body, 'plain', 'utf-8') + mail['Subject'] = subject + mail['From'] = self.mail_from + mail['To'] = ', '.join(self.mail_recipients) + mail['Reply-To'] = self.reply_to + mail['X-Mailer'] = xmailer + if self.mail_cc: + mail['Cc'] = ', '.join(self.mail_cc) + + if self.verbose > 1: + LOG.debug("Mail to send:\n{}".format(mail.as_string(unixfrom=True))) + + if self.mail_method == 'smtp': + self._send_mail_smtp(mail) + else: + self._send_mail_sendmail(mail) + + # ------------------------------------------------------------------------- + def _send_mail_smtp(self, mail): + + with smtplib.SMTP(self.mail_server, self.smtp_port) as smtp: + if self.verbose > 2: + smtp.set_debuglevel(2) + elif self.verbose > 1: + smtp.set_debuglevel(1) + + smtp.send_message(mail) + + # ------------------------------------------------------------------------- + def _send_mail_sendmail(self, mail): + + # Searching for the location of sendmail ... + paths = ( + '/usr/sbin/sendmail', + '/usr/lib/sendmail', + ) + sendmail = None + for path in paths: + if os.path.isfile(path) and os.access(path, os.X_OK): + sendmail = path + break + + if not sendmail: + msg = "Did not found sendmail executable." + LOG.error(msg) + return + + cmd = [sendmail, "-t", "-oi"] + cmd_str = ' '.join(map(lambda x: pipes.quote(x), cmd)) + LOG.debug("Executing: {}".format(cmd_str)) + + p = Popen(cmd, stdin=PIPE, universal_newlines=True) + p.communicate(mail.as_string()) + + # ------------------------------------------------------------------------- + def post_init(self): + """ + Method to execute before calling run(). Here could be done some + finishing actions after reading in commandline parameters, + configuration a.s.o. + + This method could be overwritten by descendant classes, these + methhods should allways include a call to post_init() of the + parent class. + + """ + + self.initialized = True + + # ------------------------------------------------------------------------- + def is_local_domain(self, domain): + + zone_name = RE_DOT_AT_END.sub('', domain) + + if self.verbose > 1: + LOG.debug("Checking, whether {!r} is a local zone.".format(zone_name)) + + tld = zone_name.split('.')[-1] + if tld in ('intern', 'internal', 'local', 'localdomain', 'lokal'): + LOG.debug("Zone {!r} has a local TLD {!r}.".format(zone_name, tld)) + return True + + zone_base = zone_name.split('.')[0] + if zone_base in ('intern', 'internal', 'local', 'localdomain', 'lokal'): + LOG.debug("Zone {!r} has a local base {!r}.".format(zone_name, tld)) + return True + + if tld != 'arpa': + if self.verbose > 2: + LOG.debug("Zone {!r} has a public TLD {!r}.".format(zone_name, tld)) + return False + + if zone_name.endswith('.in-addr.arpa'): + tupels = [] + for tupel in reversed(zone_name.replace('.in-addr.arpa', '').split('.')): + tupels.append(tupel) + if self.verbose > 2: + LOG.debug("Got IPv4 tupels from zone {!r}: {}".format(zone_name, pp(tupels))) + bitmask = None + if len(tupels) == 1: + bitmask = 8 + tupels.append('0') + tupels.append('0') + tupels.append('0') + elif len(tupels) == 2: + tupels.append('0') + tupels.append('0') + bitmask = 16 + elif len(tupels) == 3: + bitmask = 24 + tupels.append('0') + else: + LOG.warn("Could not interprete reverse IPv4 zone {!r}.".format(zone_name)) + return False + net_address = '.'.join(tupels) + '/{}'.format(bitmask) + if self.verbose > 2: + LOG.debug( + "Got IPv4 network address of zone {!r}: {!r}.".format( + zone_name, net_address)) + network = ipaddress.ip_network(net_address) + if network.is_global: + if self.verbose > 1: + LOG.debug( + "The network {!r} of zone {!r} is allocated for public networks.".format( + net_address, zone_name)) + return False + LOG.debug("The network {!r} of zone {!r} is allocated for local networks.".format( + net_address, zone_name)) + return True + + if self.verbose > 2: + LOG.debug( + "Zone {!r} seems to be a reverse zone for a public network.".format(zone_name)) + return False + + +# ============================================================================= + +if __name__ == "__main__": + + pass + +# ============================================================================= + +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 list diff --git a/lib/pp_admintools/deploy_zones_from_pdns.py b/lib/pp_admintools/deploy_zones_from_pdns.py new file mode 100644 index 0000000..12c7978 --- /dev/null +++ b/lib/pp_admintools/deploy_zones_from_pdns.py @@ -0,0 +1,884 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@author: Frank Brehm +@contact: frank.brehm@pixelpark.com +@copyright: © 2021 by Frank Brehm, Berlin +@summary: A module for the application class for configuring named +""" +from __future__ import absolute_import + +import os +import logging +import logging.config +import textwrap +import re +import shlex +import copy +import datetime +import socket +import tempfile +import time +import shutil +import pipes + +from subprocess import Popen, TimeoutExpired, PIPE + +from functools import cmp_to_key + +# Third party modules +import six +from pytz import timezone, UnknownTimeZoneError + +# Own modules +from fb_tools.common import pp, compare_fqdn, to_str, to_bool + +from .pdns_app import PpPDNSAppError, PpPDNSApplication + +from .pidfile import PidFileError, PidFile + +__version__ = '0.6.0' +LOG = logging.getLogger(__name__) + + +# ============================================================================= +class PpDeployZonesError(PpPDNSAppError): + pass + + +# ============================================================================= +class PpDeployZonesApp(PpPDNSApplication): + """ + Class for a application 'dns-deploy-zones' for configuring slaves + of the BIND named daemon. + """ + + default_pidfile = '/run/dns-deploy-zones.pid' + + default_named_conf_dir = '/etc' + default_named_zones_cfg_file = 'named.zones.conf' + default_named_basedir = '/var/named' + default_named_slavedir = 'slaves' + + zone_masters_local = [ + '217.66.53.87', + ] + + zone_masters_public = [ + '217.66.53.97', + ] + + default_cmd_checkconf = '/usr/sbin/named-checkconf' + default_cmd_reload = '/usr/sbin/rndc reload' + default_cmd_status = '/usr/bin/systemctl status named.service' + default_cmd_start = '/usr/bin/systemctl start named.service' + default_cmd_restart = '/usr/bin/systemctl restart named.service' + + re_ipv4_zone = re.compile(r'^((?:\d+\.)+)in-addr\.arpa\.$') + re_ipv6_zone = re.compile(r'^((?:[\da-f]\.)+)ip6\.arpa\.$') + + re_block_comment = re.compile(r'/\*.*?\*/', re.MULTILINE | re.DOTALL) + re_line_comment = re.compile(r'(?://|#).*$', re.MULTILINE) + + re_split_addresses = re.compile(r'[,;\s]+') + re_integer = re.compile(r'^\s*(\d+)\s*$') + + open_args = {} + if six.PY3: + open_args = { + 'encoding': 'utf-8', + 'errors': 'surrogateescape', + } + + # ------------------------------------------------------------------------- + def __init__(self, appname=None, base_dir=None, version=__version__): + + self.zones = [] + self.pidfile = None + + self._show_simulate_opt = True + + self.is_internal = False + self.named_listen_on_v6 = False + self.pidfile_name = self.default_pidfile + + # Configuration files and directories + self.named_conf_dir = self.default_named_conf_dir + self._named_zones_cfg_file = self.default_named_zones_cfg_file + self.named_basedir = self.default_named_basedir + self._named_slavedir = self.default_named_slavedir + + self.zone_masters = copy.copy(self.zone_masters_public) + self.masters_configured = False + + self.tempdir = None + self.temp_zones_cfg_file = None + self.keep_tempdir = False + self.keep_backup = False + + self.backup_suffix = ( + '.' + datetime.datetime.utcnow().strftime('%Y-%m-%d_%H-%M-%S') + '.bak') + + self.reload_necessary = False + self.restart_necessary = False + + self.cmd_checkconf = self.default_cmd_checkconf + self.cmd_reload = self.default_cmd_reload + self.cmd_status = self.default_cmd_status + self.cmd_start = self.default_cmd_start + self.cmd_restart = self.default_cmd_restart + + self.files2replace = {} + self.moved_files = {} + + description = textwrap.dedent('''\ + Generation of the BIND9 configuration file for slave zones. + ''') + + super(PpDeployZonesApp, self).__init__( + appname=appname, version=version, description=description, + base_dir=base_dir, cfg_stems='dns-deploy-zones', environment="public", + ) + + self.post_init() + + # ------------------------------------------- + @property + def named_zones_cfg_file(self): + """The file for configuration of all own zones.""" + return os.path.join(self.named_conf_dir, self._named_zones_cfg_file) + + # ------------------------------------------- + @property + def named_slavedir_rel(self): + """The directory for zone files of slave zones.""" + return self._named_slavedir + + # ------------------------------------------- + @property + def named_slavedir_abs(self): + """The directory for zone files of slave zones.""" + return os.path.join(self.named_basedir, self._named_slavedir) + + # ------------------------------------------------------------------------- + def init_arg_parser(self): + + super(PpDeployZonesApp, self).init_arg_parser() + + self.arg_parser.add_argument( + '-B', '--backup', dest="keep_backup", action='store_true', + help=("Keep a backup file for each changed configuration file."), + ) + + self.arg_parser.add_argument( + '-K', '--keep-tempdir', dest='keep_tempdir', action='store_true', + help=( + "Keeping the temporary directory instead of removing it at the end " + "(e.g. for debugging purposes)"), + ) + + # ------------------------------------------------------------------------- + def perform_arg_parser(self): + """ + Public available method to execute some actions after parsing + the command line parameters. + """ + + super(PpDeployZonesApp, self).perform_arg_parser() + + if self.args.keep_tempdir: + self.keep_tempdir = True + + if self.args.keep_backup: + self.keep_backup = True + + # ------------------------------------------------------------------------- + def perform_config(self): + + super(PpDeployZonesApp, self).perform_config() + + for section_name in self.cfg.keys(): + + if self.verbose > 3: + LOG.debug("Checking config section {!r} ...".format(section_name)) + + section = self.cfg[section_name] + + if section_name.lower() == 'app': + self._check_path_config(section, section_name, 'pidfile', 'pidfile_name', True) + if 'keep-backup' in section: + self.keep_backup = to_bool(section['keep-backup']) + if 'keep_backup' in section: + self.keep_backup = to_bool(section['keep_backup']) + + if section_name.lower() == 'named': + self.set_named_options(section, section_name) + + if not self.masters_configured: + if self.environment == 'local': + self.zone_masters = copy.copy(self.zone_masters_local) + else: + self.zone_masters = copy.copy(self.zone_masters_public) + + # ------------------------------------------------------------------------- + def set_named_options(self, section, section_name): + + if self.verbose > 2: + LOG.debug("Evaluating config section {n!r}:\n{s}".format( + n=section_name, s=pp(section))) + + # Configuration files and directories + self._check_path_config( + section, section_name, 'config_dir', 'named_conf_dir', True) + self._check_path_config( + section, section_name, 'zones_cfg_file', '_named_zones_cfg_file', False) + self._check_path_config(section, section_name, 'base_dir', 'named_basedir', True) + self._check_path_config(section, section_name, 'slave_dir', '_named_slavedir', False) + + if 'listen_on_v6' in section and section['listen_on_v6'] is not None: + self.named_listen_on_v6 = to_bool(section['listen_on_v6']) + + if 'masters' in section: + self._get_masters_from_cfg(section['masters'], section_name) + + for item in ('cmd_checkconf', 'cmd_reload', 'cmd_status', 'cmd_start', 'cmd_restart'): + if item in section and section[item].strip(): + setattr(self, item, section[item].strip()) + + # ------------------------------------------------------------------------- + def _get_masters_from_cfg(self, value, section_name): + + value = value.strip() + if not value: + msg = "No masters given in [{}]/masters.".format(section_name) + LOG.error(msg) + self.config_has_errors = True + return + + masters = [] + + for m in self.re_split_addresses.split(value): + if m: + m = m.strip().lower() + LOG.debug("Checking given master address {!r} ...".format(m)) + try: + addr_infos = socket.getaddrinfo( + m, 53, proto=socket.IPPROTO_TCP) + for addr_info in addr_infos: + addr = addr_info[4][0] + if not self.named_listen_on_v6 and addr_info[0] == socket.AF_INET6: + msg = ( + "Not using {!r} as a master IP address, because " + "we are not using IPv6.").format(addr) + LOG.debug(msg) + continue + if addr in masters: + LOG.debug("Address {!r} already in masters yet.".format(addr)) + else: + LOG.debug("Address {!r} not in masters yet.".format(addr)) + masters.append(addr) + + except socket.gaierror as e: + msg = ( + "Invalid hostname or address {!r} found in " + "[{}]/masters: {}").format(m, section_name, e) + LOG.error(msg) + self.config_has_errors = True + m = None + if masters: + if self.verbose > 2: + LOG.debug("Using configured masters: {}".format(pp(masters))) + self.zone_masters = masters + self.masters_configured = True + else: + LOG.warn("No valid masters found in configuration.") + + # ------------------------------------------------------------------------- + def post_init(self): + + super(PpDeployZonesApp, self).post_init() + self.initialized = False + + if not self.quiet: + print('') + + LOG.debug("Post init phase.") + + LOG.debug("Checking for masters, which are local addresses ...") + ext_masters = [] + for addr in self.zone_masters: + if addr in self.local_addresses: + LOG.debug( + "Address {!r} IS in list of local addresses.".format(addr)) + else: + LOG.debug( + "Address {!r} not in list of local addresses.".format(addr)) + ext_masters.append(addr) + self.zone_masters = ext_masters + LOG.info("Using masters for slave zones: {}".format( + ', '.join(map(lambda x: '{!r}'.format(x), self.zone_masters)))) + + self.pidfile = PidFile( + filename=self.pidfile_name, appname=self.appname, verbose=self.verbose, + base_dir=self.base_dir, simulate=self.simulate) + + self.initialized = True + + # ------------------------------------------------------------------------- + def pre_run(self): + """ + Dummy function to run before the main routine. + Could be overwritten by descendant classes. + + """ + + super(PpDeployZonesApp, self).pre_run() + + if self.environment == 'global': + LOG.error( + "Using the global DNS master is not supported, " + "please use 'local' or 'public'") + self.exit(1) + + # ------------------------------------------------------------------------- + def _run(self): + + local_tz_name = 'Europe/Berlin' + if 'TZ' in os.environ and os.environ['TZ']: + local_tz_name = os.environ['TZ'] + try: + local_tz = timezone(local_tz_name) + except UnknownTimeZoneError: + LOG.error("Unknown time zone: {!r}.".format(local_tz_name)) + self.exit(6) + + my_uid = os.geteuid() + if my_uid: + msg = "You must be root to execute this script." + if self.simulate: + LOG.warn(msg) + time.sleep(1) + else: + LOG.error(msg) + self.exit(1) + + try: + self.pidfile.create() + except PidFileError as e: + LOG.error("Could not occupy pidfile: {}".format(e)) + self.exit(7) + return + + try: + + LOG.info("Starting: {}".format( + datetime.datetime.now(local_tz).strftime('%Y-%m-%d %H:%M:%S %Z'))) + + self.zones = self.get_api_zones() + self.zones.sort(key=lambda x: cmp_to_key(compare_fqdn)(x.name_unicode)) + + self.init_temp_objects() + self.generate_slave_cfg_file() + self.compare_files() + + try: + self.replace_configfiles() + if not self.check_namedconf(): + self.restore_configfiles() + self.exit(99) + self.apply_config() + except Exception: + self.restore_configfiles() + raise + + finally: + self.cleanup() + self.pidfile = None + LOG.info("Ending: {}".format( + datetime.datetime.now(local_tz).strftime('%Y-%m-%d %H:%M:%S %Z'))) + + # ------------------------------------------------------------------------- + def cleanup(self): + + LOG.info("Cleaning up ...") + + for tgt_file in self.moved_files.keys(): + backup_file = self.moved_files[tgt_file] + LOG.debug("Searching for {!r}.".format(backup_file)) + if os.path.exists(backup_file): + if self.keep_backup: + LOG.info("Keep existing backup file {!r}.".format(backup_file)) + else: + LOG.info("Removing {!r} ...".format(backup_file)) + if not self.simulate: + os.remove(backup_file) + + # ----------------------- + def emit_rm_err(function, path, excinfo): + LOG.error("Error removing {!r} - {}: {}".format( + path, excinfo[1].__class__.__name__, excinfo[1])) + + if self.tempdir: + if self.keep_tempdir: + msg = ( + "Temporary directory {!r} will not be removed. " + "It's on yours to remove it manually.").format(self.tempdir) + LOG.warn(msg) + else: + LOG.debug("Destroying temporary directory {!r} ...".format(self.tempdir)) + shutil.rmtree(self.tempdir, False, emit_rm_err) + self.tempdir = None + + # ------------------------------------------------------------------------- + def init_temp_objects(self): + """Init temporary objects and properties.""" + + self.tempdir = tempfile.mkdtemp( + prefix=(self.appname + '.'), suffix='.tmp.d' + ) + LOG.debug("Temporary directory: {!r}.".format(self.tempdir)) + + self.temp_zones_cfg_file = os.path.join( + self.tempdir, self.default_named_zones_cfg_file) + + if self.verbose > 1: + LOG.debug("Temporary zones conf: {!r}".format(self.temp_zones_cfg_file)) + + # ------------------------------------------------------------------------- + def generate_slave_cfg_file(self): + + LOG.info("Generating {} ...".format(self.default_named_zones_cfg_file)) + + cur_date = datetime.datetime.now().isoformat(' ') + re_rev = re.compile(r'^rev\.', re.IGNORECASE) + re_trail_dot = re.compile(r'\.+$') + + lines = [] + lines.append('###############################################################') + lines.append('') + lines.append(' Bind9 configuration file for slave sones') + lines.append(' {}'.format(self.named_zones_cfg_file)) + lines.append('') + lines.append(' Generated at: {}'.format(cur_date)) + lines.append('') + lines.append('###############################################################') + header = textwrap.indent('\n'.join(lines), '//', lambda line: True) + '\n' + + content = header + + for zone in self.zones: + + canonical_name = zone.name_unicode + match = self.re_ipv4_zone.search(zone.name) + if match: + prefix = self._get_ipv4_prefix(match.group(1)) + if prefix: + if prefix == '127.0.0': + LOG.debug("Pure local zone {!r} will not be considered.".format(prefix)) + continue + canonical_name = 'rev.' + prefix + else: + match = self.re_ipv6_zone.search(zone.name) + if match: + prefix = self._get_ipv6_prefix(match.group(1)) + if prefix: + canonical_name = 'rev.' + prefix + + show_name = canonical_name + show_name = re_rev.sub('Reverse ', show_name) + show_name = re_trail_dot.sub('', show_name) + zname = re_trail_dot.sub('', zone.name) + + zfile = os.path.join( + self.named_slavedir_rel, re_trail_dot.sub('', canonical_name) + '.zone') + + lines = [] + lines.append('') + lines.append('// {}'.format(show_name)) + lines.append('zone "{}" in {{'.format(zname)) + lines.append('\tmasters {') + for master in self.zone_masters: + lines.append('\t\t{};'.format(master)) + lines.append('\t};') + lines.append('\ttype slave;') + lines.append('\tfile "{}";'.format(zfile)) + lines.append('};') + + content += '\n'.join(lines) + '\n' + + content += '\n// vim: ts=8 filetype=named noet noai\n' + + with open(self.temp_zones_cfg_file, 'w', **self.open_args) as fh: + fh.write(content) + + if self.verbose > 2: + LOG.debug("Generated {!r}:\n{}".format(self.temp_zones_cfg_file, content.strip())) + + # ------------------------------------------------------------------------- + def _get_ipv4_prefix(self, match): + + tuples = [] + for t in match.split('.'): + if t: + tuples.insert(0, t) + if self.verbose > 2: + LOG.debug("Got IPv4 tuples: {}".format(pp(tuples))) + return '.'.join(tuples) + + # ------------------------------------------------------------------------- + def _get_ipv6_prefix(self, match): + + tuples = [] + for t in match.split('.'): + if t: + tuples.insert(0, t) + + tokens = [] + while len(tuples): + token = ''.join(tuples[0:4]).ljust(4, '0') + if token.startswith('000'): + token = token[3:] + elif token.startswith('00'): + token = token[2:] + elif token.startswith('0'): + token = token[1:] + tokens.append(token) + del tuples[0:4] + + if self.verbose > 2: + LOG.debug("Got IPv6 tokens: {}".format(pp(tokens))) + + return ':'.join(tokens) + + # ------------------------------------------------------------------------- + def compare_files(self): + + LOG.info("Comparing generated files with existing ones.") + + if not self.files_equal_content(self.temp_zones_cfg_file, self.named_zones_cfg_file): + self.reload_necessary = True + self.files2replace[self.named_zones_cfg_file] = self.temp_zones_cfg_file + + if self.verbose > 1: + LOG.debug("Files to replace:\n{}".format(pp(self.files2replace))) + + # ------------------------------------------------------------------------- + def files_equal_content(self, file_src, file_tgt): + + LOG.debug("Comparing {!r} with {!r} ...".format(file_src, file_tgt)) + + if not file_src: + raise PpDeployZonesError("Source file not defined.") + if not file_tgt: + raise PpDeployZonesError("Target file not defined.") + + if not os.path.exists(file_src): + raise PpDeployZonesError("Source file {!r} does not exists.".format(file_src)) + if not os.path.isfile(file_src): + raise PpDeployZonesError("Source file {!r} is not a regular file.".format(file_src)) + + if not os.path.exists(file_tgt): + LOG.debug("Target file {!r} does not exists.".format(file_tgt)) + return False + if not os.path.isfile(file_tgt): + raise PpDeployZonesError("Target file {!r} is not a regular file.".format(file_tgt)) + + content_src = '' + if self.verbose > 2: + LOG.debug("Reading {!r} ...".format(file_src)) + with open(file_src, 'r', **self.open_args) as fh: + content_src = fh.read() + lines_str_src = self.re_block_comment.sub('', content_src) + lines_str_src = self.re_line_comment.sub('', lines_str_src) + lines_src = [] + for line in lines_str_src.splitlines(): + line = line.strip() + if line: + lines_src.append(line) + if self.verbose > 3: + LOG.debug("Cleaned version of {!r}:\n{}".format( + file_src, '\n'.join(lines_src))) + + content_tgt = '' + if self.verbose > 2: + LOG.debug("Reading {!r} ...".format(file_tgt)) + with open(file_tgt, 'r', **self.open_args) as fh: + content_tgt = fh.read() + lines_str_tgt = self.re_block_comment.sub('', content_tgt) + lines_str_tgt = self.re_line_comment.sub('', lines_str_tgt) + lines_tgt = [] + for line in lines_str_tgt.splitlines(): + line = line.strip() + if line: + lines_tgt.append(line) + if self.verbose > 3: + LOG.debug("Cleaned version of {!r}:\n{}".format( + file_tgt, '\n'.join(lines_tgt))) + + if len(lines_src) != len(lines_tgt): + LOG.debug(( + "Source file {!r} has different number essential lines ({}) than " + "the target file {!r} ({} lines).").format( + file_src, len(lines_src), file_tgt, len(lines_tgt))) + return False + + i = 0 + while i < len(lines_src): + if lines_src[i] != lines_tgt[i]: + LOG.debug(( + "Source file {!r} has a different content than " + "the target file {!r}.").format(file_src, lines_tgt)) + return False + i += 1 + + return True + + # ------------------------------------------------------------------------- + def replace_configfiles(self): + + if not self.files2replace: + LOG.debug("No replacement of any config files necessary.") + return + + LOG.debug("Start replacing of config files ...") + + for tgt_file in self.files2replace.keys(): + + backup_file = tgt_file + self.backup_suffix + + if os.path.exists(tgt_file): + self.moved_files[tgt_file] = backup_file + LOG.info("Copying {!r} => {!r} ...".format(tgt_file, backup_file)) + if not self.simulate: + shutil.copy2(tgt_file, backup_file) + + if self.verbose > 1: + LOG.debug("All backuped config files:\n{}".format(pp(self.moved_files))) + + for tgt_file in self.files2replace.keys(): + src_file = self.files2replace[tgt_file] + LOG.info("Copying {!r} => {!r} ...".format(src_file, tgt_file)) + if not self.simulate: + shutil.copy2(src_file, tgt_file) + + # ------------------------------------------------------------------------- + def restore_configfiles(self): + + LOG.error("Restoring of original config files because of an exception.") + + for tgt_file in self.moved_files.keys(): + backup_file = self.moved_files[tgt_file] + LOG.info("Moving {!r} => {!r} ...".format(backup_file, tgt_file)) + if not self.simulate: + if os.path.exists(backup_file): + os.rename(backup_file, tgt_file) + else: + LOG.error("Could not find backup file {!r}.".format(backup_file)) + + # ------------------------------------------------------------------------- + def check_namedconf(self): + + LOG.info("Checking syntax correctness of named.conf ...") + cmd = shlex.split(self.cmd_checkconf) + if 'named-checkconf' in self.cmd_checkconf and self.verbose > 2: + cmd.append('-p') + cmd_str = ' '.join(map(lambda x: pipes.quote(x), cmd)) + LOG.debug("Executing: {}".format(cmd_str)) + + std_out = None + std_err = None + ret_val = None + + with Popen(cmd, stdout=PIPE, stderr=PIPE) as proc: + try: + std_out, std_err = proc.communicate(timeout=10) + except TimeoutExpired: + proc.kill() + std_out, std_err = proc.communicate() + ret_val = proc.wait() + + LOG.debug("Return value: {!r}".format(ret_val)) + if std_out and std_out.strip(): + s = to_str(std_out.strip()) + LOG.warn("Output on STDOUT: {}".format(s)) + if std_err and std_err.strip(): + s = to_str(std_err.strip()) + LOG.warn("Output on STDERR: {}".format(s)) + + if ret_val: + return False + + return True + + # ------------------------------------------------------------------------- + def apply_config(self): + + if not self.reload_necessary and not self.restart_necessary: + LOG.info("Reload or restart of named is not necessary.") + return + + running = self.named_running() + if not running: + LOG.warn("Named is not running, please start it manually.") + return + + if self.restart_necessary: + self.restart_named() + else: + self.reload_named() + + # ------------------------------------------------------------------------- + def named_running(self): + + LOG.debug("Checking, whether named is running ...") + + cmd = shlex.split(self.cmd_status) + cmd_str = ' '.join(map(lambda x: pipes.quote(x), cmd)) + LOG.debug("Executing: {}".format(cmd_str)) + + std_out = None + std_err = None + ret_val = None + + with Popen(cmd, stdout=PIPE, stderr=PIPE) as proc: + try: + std_out, std_err = proc.communicate(timeout=10) + except TimeoutExpired: + proc.kill() + std_out, std_err = proc.communicate() + ret_val = proc.wait() + + LOG.debug("Return value: {!r}".format(ret_val)) + if std_out and std_out.strip(): + s = to_str(std_out.strip()) + LOG.debug("Output on STDOUT:\n{}".format(s)) + if std_err and std_err.strip(): + s = to_str(std_err.strip()) + LOG.warn("Output on STDERR: {}".format(s)) + + if ret_val: + return False + + return True + + # ------------------------------------------------------------------------- + def start_named(self): + + LOG.info("Starting named ...") + + cmd = shlex.split(self.cmd_start) + cmd_str = ' '.join(map(lambda x: pipes.quote(x), cmd)) + LOG.debug("Executing: {}".format(cmd_str)) + + if self.simulate: + return + + std_out = None + std_err = None + ret_val = None + + with Popen(cmd, stdout=PIPE, stderr=PIPE) as proc: + try: + std_out, std_err = proc.communicate(timeout=30) + except TimeoutExpired: + proc.kill() + std_out, std_err = proc.communicate() + ret_val = proc.wait() + + LOG.debug("Return value: {!r}".format(ret_val)) + if std_out and std_out.strip(): + s = to_str(std_out.strip()) + LOG.debug("Output on STDOUT:\n{}".format(s)) + if std_err and std_err.strip(): + s = to_str(std_err.strip()) + LOG.error("Output on STDERR: {}".format(s)) + + if ret_val: + return False + + return True + + # ------------------------------------------------------------------------- + def restart_named(self): + + LOG.info("Restarting named ...") + + cmd = shlex.split(self.cmd_restart) + cmd_str = ' '.join(map(lambda x: pipes.quote(x), cmd)) + LOG.debug("Executing: {}".format(cmd_str)) + + if self.simulate: + return + + std_out = None + std_err = None + ret_val = None + + with Popen(cmd, stdout=PIPE, stderr=PIPE) as proc: + try: + std_out, std_err = proc.communicate(timeout=30) + except TimeoutExpired: + proc.kill() + std_out, std_err = proc.communicate() + ret_val = proc.wait() + + LOG.debug("Return value: {!r}".format(ret_val)) + if std_out and std_out.strip(): + s = to_str(std_out.strip()) + LOG.debug("Output on STDOUT:\n{}".format(s)) + if std_err and std_err.strip(): + s = to_str(std_err.strip()) + LOG.error("Output on STDERR: {}".format(s)) + + if ret_val: + return False + + return True + + # ------------------------------------------------------------------------- + def reload_named(self): + + LOG.info("Reloading named ...") + + cmd = shlex.split(self.cmd_reload) + cmd_str = ' '.join(map(lambda x: pipes.quote(x), cmd)) + LOG.debug("Executing: {}".format(cmd_str)) + + if self.simulate: + return + + std_out = None + std_err = None + ret_val = None + + with Popen(cmd, stdout=PIPE, stderr=PIPE) as proc: + try: + std_out, std_err = proc.communicate(timeout=30) + except TimeoutExpired: + proc.kill() + std_out, std_err = proc.communicate() + ret_val = proc.wait() + + LOG.debug("Return value: {!r}".format(ret_val)) + if std_out and std_out.strip(): + s = to_str(std_out.strip()) + LOG.debug("Output on STDOUT:\n{}".format(s)) + if std_err and std_err.strip(): + s = to_str(std_err.strip()) + LOG.error("Output on STDERR: {}".format(s)) + + if ret_val: + return False + + return True + + +# ============================================================================= + +if __name__ == "__main__": + + pass + +# ============================================================================= + +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 list diff --git a/lib/pp_admintools/errors.py b/lib/pp_admintools/errors.py new file mode 100644 index 0000000..ec57c35 --- /dev/null +++ b/lib/pp_admintools/errors.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@author: Frank Brehm +@summary: module for some common used error classes +""" + +# own modules +from fb_tools.errors import FbError, FbAppError + + +__version__ = '0.6.0' + +# ============================================================================= +class PpError(FbError): + """ + Base error class for all other self defined exceptions. + """ + + pass + + +# ============================================================================= +class PpAppError(FbAppError): + + pass + + +# ============================================================================= + +if __name__ == "__main__": + pass + +# ============================================================================= + +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 diff --git a/lib/pp_admintools/global_version.py b/lib/pp_admintools/global_version.py new file mode 100644 index 0000000..059ca9c --- /dev/null +++ b/lib/pp_admintools/global_version.py @@ -0,0 +1,15 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +""" +@author: Frank Brehm +@contact: frank.brehm@pixelpark.com +@copyright: © 2018 by Frank Brehm, Publicis Pixelpark GmbH, Berlin +@summary: Modules global version number +""" + +__author__ = 'Frank Brehm ' +__contact__ = 'frank.brehm@pixelpark.com' +__version__ = '0.8.0' +__license__ = 'LGPL3+' + +# vim: fileencoding=utf-8 filetype=python ts=4 diff --git a/lib/pp_admintools/mailaddress.py b/lib/pp_admintools/mailaddress.py new file mode 100644 index 0000000..d7baef4 --- /dev/null +++ b/lib/pp_admintools/mailaddress.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@author: Frank Brehm +@contact: frank.brehm@pixelpark.com +@copyright: © 2021 by Frank Brehm, Publicis Pixelpark GmbH, Berlin +@summary: The module for the MailAddress object. +""" +from __future__ import absolute_import + +# Standard modules +import logging +import re + +# Own modules +from fb_tools.errors import InvalidMailAddressError + +from fb_tools.common import to_str + +from fb_tools.obj import FbGenericBaseObject + +__version__ = '0.5.0' +log = logging.getLogger(__name__) + + +# ============================================================================= +class MailAddress(FbGenericBaseObject): + """ + Class for encapsulating a mail simple address. + """ + + pattern_valid_domain = r'@((?:[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?\.)+[a-z][a-z]+)$' + + pattern_valid_user = r'^([a-z0-9][a-z0-9_\-\.\+\&@]*[a-z0-9]' + pattern_valid_user += r'(?:\+[a-z0-9][a-z0-9_\-\.]*[a-z0-9])*)' + + pattern_valid_address = pattern_valid_user + pattern_valid_domain + + re_valid_user = re.compile(pattern_valid_user + r'$', re.IGNORECASE) + re_valid_domain = re.compile(r'^' + pattern_valid_domain, re.IGNORECASE) + re_valid_address = re.compile(pattern_valid_address, re.IGNORECASE) + + verbose = 0 + + # ------------------------------------------------------------------------- + @classmethod + def valid_address(cls, address, raise_on_failure=False): + + if not address: + e = InvalidMailAddressError(address, "Empty address.") + if raise_on_failure: + raise e + elif cls.verbose > 2: + log.debug(str(e)) + return False + + addr = to_str(address) + if not isinstance(addr, str): + e = InvalidMailAddressError(address, "Wrong type.") + if raise_on_failure: + raise e + elif cls.verbose > 2: + log.debug(str(e)) + return False + + if cls.re_valid_address.search(addr): + return True + + e = InvalidMailAddressError(address, "Invalid address.") + if raise_on_failure: + raise e + elif cls.verbose > 2: + log.debug(str(e)) + return False + + # ------------------------------------------------------------------------- + def __init__(self, user=None, domain=None): + + self._user = '' + self._domain = '' + + if not domain: + if user: + addr = to_str(user) + if self.valid_address(addr): + match = self.re_valid_address.search(addr) + self._user = match.group(1) + self._domain = match.group(2) + return + match = self.re_valid_domain.search(addr) + if match: + self._domain = match.group(1) + return + self._user = addr + return + + self._user = to_str(user) + self._domain = to_str(domain) + + # ----------------------------------------------------------- + @property + def user(self): + """The user part of the address.""" + if self._user is None: + return '' + return self._user + + # ----------------------------------------------------------- + @property + def domain(self): + """The domain part of the address.""" + if self._domain is None: + return '' + return self._domain + + # ------------------------------------------------------------------------- + def __str__(self): + + if not self.user and not self.domain: + return '' + + if not self.domain: + return self.user + + if not self.user: + return '@' + self.domain + + return self.user + '@' + self.domain + + # ------------------------------------------------------------------------- + def str_for_access(self): + + if not self.user and not self.domain: + return None + + if not self.domain: + return self.user + '@' + + if not self.user: + return self.domain + + return self.user + '@' + self.domain + + # ------------------------------------------------------------------------- + def __repr__(self): + """Typecasting into a string for reproduction.""" + + out = "<%s(" % (self.__class__.__name__) + + fields = [] + fields.append("user={!r}".format(self.user)) + fields.append("domain={!r}".format(self.domain)) + + out += ", ".join(fields) + ")>" + return out + + # ------------------------------------------------------------------------- + def __hash__(self): + return hash(str(self).lower()) + + # ------------------------------------------------------------------------- + def __eq__(self, other): + + if not isinstance(other, MailAddress): + if other is None: + return False + return str(self).lower() == str(other).lower() + + if not self.user: + if other.user: + return False + if not self.domain: + if other.domain: + return False + return True + if not other.domain: + return False + if self.domain.lower() == other.domain.lower(): + return True + return False + + if not self.domain: + if other.domain: + return False + if not other.user: + return False + if self.user.lower() == other.user.lower(): + return True + return False + + if not other.user: + return False + if not other.domain: + return False + if self.domain.lower() != other.domain.lower(): + return False + if self.user.lower() != other.user.lower(): + return False + + return True + + # ------------------------------------------------------------------------- + def __ne__(self, other): + + if self == other: + return False + return True + + # ------------------------------------------------------------------------- + def __lt__(self, other): + + if not isinstance(other, MailAddress): + if other is None: + return False + return str(self).lower() < str(other).lower() + + if not self.user: + if not self.domain: + if other.domain: + return False + return True + if not other.domain: + return False + if self.domain.lower() != other.domain.lower(): + return self.domain.lower() < other.domain.lower() + if other.user: + return False + return True + + if not self.domain: + if other.domain: + return True + if not other.user: + return False + if self.user.lower() != other.user.lower(): + return self.user.lower() < other.user.lower() + return False + + if not other.domain: + return False + if not other.user: + return False + + if self.domain.lower() != other.domain.lower(): + return self.domain.lower() < other.domain.lower() + if self.user.lower() != other.user.lower(): + return self.user.lower() < other.user.lower() + + return False + + # ------------------------------------------------------------------------- + def __gt__(self, other): + + if not isinstance(other, MailAddress): + return NotImplemented + + if self < other: + return False + return True + + # ------------------------------------------------------------------------- + def __copy__(self): + "Implementing a wrapper for copy.copy()." + + addr = MailAddress() + addr._user = self.user + addr._domain = self.domain + return addr + + +# ============================================================================= + +if __name__ == "__main__": + + pass + +# ============================================================================= + +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 list diff --git a/lib/pp_admintools/merge.py b/lib/pp_admintools/merge.py new file mode 100644 index 0000000..2810374 --- /dev/null +++ b/lib/pp_admintools/merge.py @@ -0,0 +1,75 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +""" +@author: Frank Brehm +@contact: frank.brehm@pixelpark.com +""" + +import itertools + +__version__ = '0.2.0' + + +# ============================================================================= +class ZipExhausted(Exception): + pass + + +# ============================================================================= +def izip_longest(*args, **kwds): + ''' + Function izip_longest() does not exists anymore in Python3 itertools. + Taken from https://docs.python.org/2/library/itertools.html#itertools.izip_longest + ''' + # izip_longest('ABCD', 'xy', fillvalue='-') --> Ax By C- D- + + fillvalue = kwds.get('fillvalue') + counter = [len(args) - 1] + + # ------------------ + def sentinel(): + if not counter[0]: + raise ZipExhausted + counter[0] -= 1 + yield fillvalue + + # ------------------ + fillers = itertools.repeat(fillvalue) + iterators = [itertools.chain(it, sentinel(), fillers) for it in args] + try: + while iterators: + yield tuple(map(next, iterators)) + except ZipExhausted: + pass + + +# ============================================================================= +def merge_structure(a, b): + ''' + Taken from https://gist.github.com/saurabh-hirani/6f3f5d119076df70e0da + ''' + if isinstance(a, dict) and isinstance(b, dict): + d = dict(a) + d.update({k: merge_structure(a.get(k, None), b[k]) for k in b}) + return d + + if isinstance(a, list) and isinstance(b, list): + is_a_nested = any(x for x in a if isinstance(x, list) or isinstance(x, dict)) + is_b_nested = any(x for x in b if isinstance(x, list) or isinstance(x, dict)) + if is_a_nested or is_b_nested: + return [merge_structure(x, y) for x, y in izip_longest(a, b)] + else: + return a + b + + return a if b is None else b + + +# ============================================================================= + +if __name__ == "__main__": + + pass + +# ============================================================================= + +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 diff --git a/lib/pp_admintools/pdns_app.py b/lib/pp_admintools/pdns_app.py new file mode 100644 index 0000000..eada8db --- /dev/null +++ b/lib/pp_admintools/pdns_app.py @@ -0,0 +1,857 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@author: Frank Brehm +@contact: frank.brehm@pixelpark.com +@copyright: © 2021 by Frank Brehm, Berlin +@summary: The module for a application object related to PowerDNS. +""" +from __future__ import absolute_import + +# Standard modules +import logging +import logging.config +import re +import copy +import json +import os +import ipaddress +import socket +import getpass +import time + +# Third party modules +import requests +import psutil + +# Own modules +from fb_tools.common import pp + +from fb_pdnstools.zone import PowerDNSZone +from fb_pdnstools.record import PowerDnsSOAData + +from .cfg_app import PpCfgAppError, PpConfigApplication + +__version__ = '0.7.0' +LOG = logging.getLogger(__name__) +_LIBRARY_NAME = "pp-pdns-api-client" + + +# ============================================================================= +class PpPDNSAppError(PpCfgAppError): + """Base error class for all exceptions happened during + execution this configured application""" + pass + + +# ============================================================================= +class PDNSApiError(PpPDNSAppError): + """Base class for more complex exceptions""" + def __init__(self, resp, content, uri=None): + self.resp = resp + self.content = content + self.uri = uri + + +# ============================================================================= +class PDNSApiNotAuthorizedError(PDNSApiError): + """The authorization information provided is not correct""" + pass + + +# ============================================================================= +class PDNSApiNotFoundError(PDNSApiError): + """The ProfitBricks entity was not found""" + pass + + +# ============================================================================= +class PDNSApiValidationError(PDNSApiError): + """The HTTP data provided is not valid""" + pass + + +# ============================================================================= +class PDNSApiRateLimitExceededError(PDNSApiError): + """The number of requests sent have exceeded the allowed API rate limit""" + pass + + +# ============================================================================= +class PDNSApiRequestError(PDNSApiError): + """Base error for request failures""" + pass + + +# ============================================================================= +class PDNSApiTimeoutError(PDNSApiRequestError): + """Raised when a request does not finish in the given time span.""" + pass + + +# ============================================================================= +class PpPDNSApplication(PpConfigApplication): + """ + Class for configured application objects related to PowerDNS. + """ + + api_keys = { + 'global': "6d1b08e2-59c6-49e7-9e48-039ade102016", + 'public': "cf0fb928-2a73-49ec-86c2-36e85c9672ff", + 'local': "d94b183a-c50d-47f7-b338-496090af1577" + } + + api_hosts = { + 'global': "dnsmaster.pp-dns.com", + 'public': "dnsmaster-public.pixelpark.com", + 'local': "dnsmaster-local.pixelpark.com" + } + + default_api_port = 8081 + default_api_servername = "localhost" + default_timeout = 20 + + # ------------------------------------------------------------------------- + def __init__( + self, appname=None, verbose=0, version=__version__, base_dir=None, + initialized=None, usage=None, description=None, + argparse_epilog=None, argparse_prefix_chars='-', env_prefix=None, + cfg_dir=None, cfg_stems=None, cfg_encoding='utf-8', need_config_file=False, + environment='global'): + + self._api_key = self.api_keys['global'] + self._api_host = self.api_hosts['global'] + self._api_port = self.default_api_port + self._api_servername = self.default_api_servername + self._api_server_version = 'unknown' + self._user_agent = '{}/{}'.format(_LIBRARY_NAME, self.version) + self._timeout = self.default_timeout + + self.local_addresses = [] + + self._environment = 'global' + if environment != 'global': + self.environment = environment + + stems = [] + if cfg_stems: + if isinstance(cfg_stems, list): + for stem in cfg_stems: + s = str(stem).strip() + if not s: + msg = "Invalid configuration stem {!r} given.".format(stem) + raise PpPDNSAppError(msg) + stems.append(s) + else: + s = str(cfg_stems).strip() + if not s: + msg = "Invalid configuration stem {!r} given.".format(cfg_stems) + raise PpPDNSAppError(msg) + stems.append(s) + else: + stems = [self.appname] + if 'pdns-api' not in stems: + stems.insert(0, 'pdns-api') + + super(PpPDNSApplication, self).__init__( + appname=appname, verbose=verbose, version=version, base_dir=base_dir, + initialized=False, usage=usage, description=description, + argparse_epilog=argparse_epilog, argparse_prefix_chars=argparse_prefix_chars, + env_prefix=env_prefix, cfg_dir=cfg_dir, cfg_stems=stems, + cfg_encoding=cfg_encoding, need_config_file=need_config_file, + ) + + for interface, snics in psutil.net_if_addrs().items(): + for snic in snics: + if snic.family == socket.AF_INET or snic.family == socket.AF_INET6: + addr = str(ipaddress.ip_address(re.sub(r'%.*', '', snic.address))) + if addr not in self.local_addresses: + self.local_addresses.append(addr) + + self._user_agent = '{}/{}'.format(_LIBRARY_NAME, self.version) + + # ----------------------------------------------------------- + @property + def api_key(self): + "The API key to use the PowerDNS API" + return self._api_key + + @api_key.setter + def api_key(self, value): + if value is None or str(value).strip() == '': + raise PpPDNSAppError("Invalid API key {!r} given.".format(value)) + self._api_key = str(value).strip() + + # ----------------------------------------------------------- + @property + def api_host(self): + "The host name or address providing the PowerDNS API." + return self._api_host + + @api_host.setter + def api_host(self, value): + if value is None or str(value).strip() == '': + raise PpPDNSAppError("Invalid API host {!r} given.".format(value)) + self._api_host = str(value).strip().lower() + + # ----------------------------------------------------------- + @property + def api_port(self): + "The TCP port number of the PowerDNS API." + return self._api_port + + @api_port.setter + def api_port(self, value): + v = int(value) + if v < 1: + raise PpPDNSAppError("Invalid API port {!r} given.".format(value)) + self._api_port = v + + # ----------------------------------------------------------- + @property + def api_servername(self): + "The (virtual) name of the PowerDNS server used in API calls." + return self._api_servername + + @api_servername.setter + def api_servername(self, value): + if value is None or str(value).strip() == '': + raise PpPDNSAppError("Invalid API server name {!r} given.".format(value)) + self._api_servername = str(value).strip() + + # ----------------------------------------------------------- + @property + def api_server_version(self): + "The version of the PowerDNS server, how provided by API." + return self._api_server_version + + # ----------------------------------------------------------- + @property + def user_agent(self): + "The name of the user agent used in API calls." + return self._user_agent + + @user_agent.setter + def user_agent(self, value): + if value is None or str(value).strip() == '': + raise PpPDNSAppError("Invalid user agent {!r} given.".format(value)) + self._user_agent = str(value).strip() + + # ----------------------------------------------------------- + @property + def timeout(self): + "The timeout in seconds on requesting the PowerDNS API." + return self._timeout + + @timeout.setter + def timeout(self, value): + v = int(value) + if v < 1: + raise PpPDNSAppError("Invalid timeout {!r} given.".format(value)) + self._timeout = v + + # ----------------------------------------------------------- + @property + def environment(self): + "The name of the PowerDNS environment." + return self._environment + + @environment.setter + def environment(self, value): + if value is None: + raise PpPDNSAppError("Invalid environment None given.") + v = str(value).strip().lower() + if v not in self.api_keys.keys(): + raise PpPDNSAppError("Invalid environment {!r} given.".format(value)) + self._environment = v + self._api_host = self.api_hosts[v] + self._api_key = self.api_keys[v] + + # ------------------------------------------------------------------------- + def as_dict(self, short=True): + """ + Transforms the elements of the object into a dict + + @param short: don't include local properties in resulting dict. + @type short: bool + + @return: structure as dict + @rtype: dict + """ + + res = super(PpPDNSApplication, self).as_dict(short=short) + res['api_host'] = self.api_host + res['api_hosts'] = copy.copy(self.api_hosts) + res['api_key'] = self.api_key + res['api_keys'] = copy.copy(self.api_keys) + res['api_port'] = self.api_port + res['api_servername'] = self.api_servername + res['default_api_port'] = self.default_api_port + res['default_api_servername'] = self.default_api_servername + res['default_timeout'] = self.default_timeout + res['environment'] = self.environment + res['timeout'] = self.timeout + res['user_agent'] = self.user_agent + res['api_server_version'] = self.api_server_version + + return res + + # ------------------------------------------------------------------------- + def init_arg_parser(self): + """ + Method to initiate the argument parser. + + This method should be explicitely called by all init_arg_parser() + methods in descendant classes. + """ + + super(PpPDNSApplication, self).init_arg_parser() + + pdns_group = self.arg_parser.add_argument_group('PowerDNS API options') + env_group = pdns_group.add_mutually_exclusive_group() + + envs = [] + for env in self.api_keys.keys(): + envs.append(str(env)) + envs.sort() + + env_group.add_argument( + '-E', '--env', '--environment', + metavar="ENVIRONMENT", choices=envs, dest="env", + help=( + "Select, which PowerDNS environment to use. " + "Valid values: {v}, default: {d!r}.".format( + v=', '.join(map(lambda x: repr(x), envs)), + d='global')) + ) + + env_group.add_argument( + '-G', '--global', + action='store_true', dest="env_global", + help=("Using the 'global' PowerDNS environment."), + ) + + env_group.add_argument( + '-L', '--local', + action='store_true', dest="env_local", + help=("Using the 'local' PowerDNS environment."), + ) + + env_group.add_argument( + '-P', '--public', + action='store_true', dest="env_public", + help=("Using the 'public' PowerDNS environment."), + ) + + pdns_group.add_argument( + '-p', '--port', + metavar="PORT", type=int, dest='api_port', default=self.default_api_port, + help=("Which port to connect to PowerDNS API, default: {}.".format( + self.default_api_port)), + ) + + pdns_group.add_argument( + '-t', '--timeout', + metavar="SECS", type=int, dest='timeout', default=self.default_timeout, + help=("The timeout in seconds to request the PowerDNS API, default: {}.".format( + self.default_timeout)), + ) + + # ------------------------------------------------------------------------- + def perform_arg_parser(self): + """ + Public available method to execute some actions after parsing + the command line parameters. + """ + + if self.args.env: + self.environment = self.args.env + elif self.args.env_global: + self.environment = 'global' + elif self.args.env_local: + self.environment = 'local' + elif self.args.env_public: + self.environment = 'public' + + if self.args.api_port: + self.api_port = self.args.api_port + + if self.args.timeout: + self.timeout = self.args.timeout + + # ------------------------------------------------------------------------- + def perform_config(self): + + super(PpPDNSApplication, self).perform_config() + + for section_name in self.cfg.keys(): + + if self.verbose > 3: + LOG.debug("Checking config section {!r} ...".format(section_name)) + + section = self.cfg[section_name] + + if section_name.lower() in ( + 'powerdns-api', 'powerdns_api', 'powerdnsapi', + 'pdns-api', 'pdns_api', 'pdnsapi'): + self.set_cfg_api_options(section, section_name) + + # ------------------------------------------------------------------------- + def set_cfg_api_options(self, section, section_name): + + if self.verbose > 2: + LOG.debug("Evaluating config section {n!r}:\n{s}".format( + n=section_name, s=pp(section))) + + if 'environment' in section: + v = section['environment'].strip().lower() + if v not in self.api_hosts: + LOG.error("Wrong environment {!r} found in configuration.".format( + section['environment'])) + self.config_has_errors = True + else: + self.environment = v + + if 'host' in section: + v = section['host'] + host = v.lower().strip() + if host: + self.api_host = host + + if 'port' in section: + try: + port = int(section['port']) + if port <= 0 or port > 2**16: + raise ValueError( + "a port must be greater than 0 and less than {}.".format(2**16)) + except (TypeError, ValueError) as e: + LOG.error("Wrong port number {!r} in configuration section {!r}: {}".format( + section['port'], section_name, e)) + self.config_has_errors = True + else: + self.api_port = port + + if 'server_id' in section and section['server_id'].strip(): + self.api_servername = section['server_id'].strip().lower() + + if 'key' in section: + key = section['key'].strip() + self.api_key = key + + # ------------------------------------------------------------------------- + def _check_path_config(self, section, section_name, key, class_prop, absolute=True, desc=None): + + if key not in section: + return + + d = '' + if desc: + d = ' ' + str(desc).strip() + + path = section[key].strip() + if not path: + msg = "No path given for{} [{}]/{} in configuration.".format( + d, section_name, key) + LOG.error(msg) + self.config_has_errors = True + return + + if absolute and not os.path.isabs(path): + msg = "Path {!r} for{} [{}]/{} in configuration must be an absolute path.".format( + path, d, section_name, key) + LOG.error(msg) + self.config_has_errors = True + return + + setattr(self, class_prop, path) + + # ------------------------------------------------------------------------- + def pre_run(self): + """ + Dummy function to run before the main routine. + Could be overwritten by descendant classes. + + """ + + if self.verbose > 1: + LOG.debug("executing pre_run() ...") + + LOG.debug("Setting Loglevel of the requests module to WARNING") + logging.getLogger("requests").setLevel(logging.WARNING) + + super(PpPDNSApplication, self).pre_run() + self.get_api_server_version() + + # ------------------------------------------------------------------------- + def _run(self): + """ + Dummy function as main routine. + + MUST be overwritten by descendant classes. + + """ + LOG.debug("Executing nothing ...") + + # ------------------------------------------------------------------------- + def post_run(self): + """ + Dummy function to run after the main routine. + Could be overwritten by descendant classes. + + """ + + if self.verbose > 1: + LOG.debug("executing post_run() ...") + + # ------------------------------------------------------------------------- + def get_api_server_version(self): + + path = "/servers/{}".format(self.api_servername) + try: + json_response = self.perform_request(path) + except (PDNSApiNotFoundError, PDNSApiValidationError): + LOG.error("Could not found server info.") + return None + if self.verbose > 2: + LOG.debug("Got a response:\n{}".format(pp(json_response))) + + if 'version' in json_response: + self._api_server_version = json_response['version'] + LOG.info("PowerDNS server version {!r}.".format(self.api_server_version)) + return self.api_server_version + LOG.error("Did not found version info in server info:\n{}".format(pp(json_response))) + return None + + # ------------------------------------------------------------------------- + def _build_url(self, path): + + url = 'http://{}'.format(self.api_host) + if self.api_port != 80: + url += ':{}'.format(self.api_port) + + url += '/api/v1' + path + LOG.debug("Used URL: {!r}".format(url)) + return url + + # ------------------------------------------------------------------------- + def perform_request(self, path, method='GET', data=None, headers=None, may_simulate=False): + """Performing the underlying API request.""" + + if headers is None: + headers = dict() + headers['X-API-Key'] = self.api_key + + url = self._build_url(path) + if self.verbose > 1: + LOG.debug("Request method: {!r}".format(method)) + if data and self.verbose > 2: + data_out = "{!r}".format(data) + try: + data_out = json.loads(data) + except ValueError: + pass + else: + data_out = pp(data_out) + LOG.debug("Data:\n{}".format(data_out)) + LOG.debug("RAW data:\n{}".format(data)) + + headers.update({'User-Agent': self.user_agent}) + headers.update({'Content-Type': 'application/json'}) + if self.verbose > 1: + LOG.debug("Headers:\n%s", pp(headers)) + + if may_simulate and self.simulate: + LOG.debug("Simulation mode, Request will not be sent.") + return '' + + session = requests.Session() + response = session.request(method, url, data=data, headers=headers, timeout=self.timeout) + + try: + if not response.ok: + LOG.debug + err = response.json() + code = response.status_code + msg = err['error'] + if response.status_code == 401: + raise PDNSApiNotAuthorizedError(code, msg, url) + if response.status_code == 404: + raise PDNSApiNotFoundError(code, msg, url) + if response.status_code == 422: + raise PDNSApiValidationError(code, msg, url) + if response.status_code == 429: + raise PDNSApiRateLimitExceededError(code, msg, url) + else: + raise PDNSApiError(code, msg, url) + + except ValueError: + raise PpPDNSAppError('Failed to parse the response', response.text) + + if self.verbose > 3: + LOG.debug("RAW response: {!r}.".format(response.text)) + if not response.text: + return '' + + json_response = response.json() + + if 'location' in response.headers: + json_response['requestId'] = self._request_id(response.headers) + + return json_response + + # ------------------------------------------------------------------------- + def get_api_zones(self): + + LOG.debug("Trying to get all zones from PDNS API ...") + + path = "/servers/{}/zones".format(self.api_servername) + json_response = self.perform_request(path) + if self.verbose > 3: + LOG.debug("Got a response:\n{}".format(pp(json_response))) + + zone_list = [] + + for data in json_response: + zone = PowerDNSZone.init_from_dict( + data, appname=self.appname, verbose=self.verbose, base_dir=self.base_dir) + zone_list.append(zone) + if self.verbose > 2: + print("{!r}".format(zone)) + + if self.verbose > 1: + LOG.debug("Found {} zones.".format(len(zone_list))) + + return zone_list + + # ------------------------------------------------------------------------- + def get_api_zone(self, zone_name): + + zone_unicode = zone_name + json_response = None + zout = "{!r}".format(zone_name) + if 'xn--' in zone_name: + zone_unicode = zone_name.encode('idna').decode('idna') + zout = "{!r} ({})".format(zone_name, zone_unicode) + LOG.debug("Trying to get complete information about zone {!r} ...".format(zone_name)) + + path = "/servers/{}/zones/{}".format(self.api_servername, zone_name) + try: + json_response = self.perform_request(path) + except (PDNSApiNotFoundError, PDNSApiValidationError): + LOG.error("The given zone {} was not found.".format(zout)) + return None + if self.verbose > 2: + LOG.debug("Got a response:\n{}".format(pp(json_response))) + + zone = PowerDNSZone.init_from_dict( + json_response, appname=self.appname, verbose=self.verbose, base_dir=self.base_dir) + if self.verbose > 2: + LOG.debug("Zone object:\n{}".format(pp(zone.as_dict()))) + + return zone + + # ------------------------------------------------------------------------- + def patch_zone(self, zone, payload): + + if self.verbose > 1: + LOG.debug("Patching zone {!r} ...".format(zone.name)) + + path = "/servers/{}/zones/{}".format(self.api_servername, zone.name) + return self.perform_request(path, 'PATCH', json.dumps(payload), may_simulate=True) + + # ------------------------------------------------------------------------- + def update_soa(self, zone, new_soa, comment=None, ttl=None): + + if not isinstance(new_soa, PowerDnsSOAData): + msg = "New SOA must by of type PowerDnsSOAData, given {t}: {s!r}".format( + t=new_soa.__class__.__name__, s=new_soa) + raise TypeError(msg) + + if ttl: + ttl = int(ttl) + else: + cur_soa_rrset = zone.get_soa_rrset() + ttl = cur_soa_rrset.ttl + + if comment is not None: + comment = str(comment).strip() + if comment == '': + comment = None + + rrset = { + 'name': zone.name, + 'type': 'SOA', + 'ttl': ttl, + 'changetype': 'REPLACE', + 'records': [], + 'comments': [], + } + +# if comment: +# comment_rec = { +# 'content': comment, +# 'account': getpass.getuser(), +# 'modified_at': int(time.time() + 0.5), +# } +# rrset['comments'] = [comment_rec] + + record = { + 'content': new_soa.data, + 'disabled': False, + 'name': zone.name, + 'set-ptr': False, + 'type': 'SOA', + } + rrset['records'].append(record) + payload = {"rrsets": [rrset]} + + if self.verbose > 1: + LOG.debug("Setting new SOA {s!r} for zone {z!r}, TTL {t} ...".format( + s=new_soa.data, z=zone.name, t=ttl)) + + self.patch_zone(zone, payload) + + # ------------------------------------------------------------------------- + def increase_serial(self, zone_name, comment=None): + + zone = self.get_api_zone(zone_name) + if not zone: + raise PpPDNSAppError("Did not found zone for {!r}.".format(zone_name)) + + LOG.info("Increasing serial in SOA of zone {!r} ....".format(zone_name)) + + api_host_address = None + for addr_info in socket.getaddrinfo(self.api_host, 53, family=socket.AF_INET): + api_host_address = addr_info[4][0] + break + + api_soa = zone.get_soa() + if not api_soa: + raise PpPDNSAppError("Could not find SOA for zone {!r}.".format(zone_name)) + if self.verbose > 2: + LOG.debug("Got SOA for zone {z!r} by API:\n{s}".format( + z=zone_name, s=api_soa)) + + dns_soa = zone.get_soa_by_dns(api_host_address) + if self.verbose > 2: + LOG.debug("Got SOA for zone {z!r} from DNS by {h!r}:\n{s}".format( + h=self.api_host, z=zone_name, s=dns_soa)) + + new_serial = zone.get_new_serial(dns_soa.serial) + LOG.debug("Got new serial number for zone {z!r}: {s}.".format( + z=zone_name, s=new_serial)) + + api_soa.serial = new_serial + return self.update_soa(zone, api_soa, comment) + + # ------------------------------------------------------------------------- + def set_nameservers( + self, zone, new_nameservers, for_zone=None, comment=None, new_ttl=None, + do_serial=True, do_notify=True): + + current_nameservers = zone.get_zone_nameservers(for_zone=for_zone) + if for_zone: + LOG.debug("Current nameservers of {f!r} in zone {z!r}:\n{ns}".format( + f=for_zone, z=zone.name, ns=pp(current_nameservers))) + else: + LOG.debug("Current nameservers of zone {z!r}:\n{ns}".format( + z=zone.name, ns=pp(current_nameservers))) + + ns2remove = [] + ns2add = [] + + for ns in current_nameservers: + if ns not in new_nameservers: + ns2remove.append(ns) + for ns in new_nameservers: + if ns not in current_nameservers: + ns2add.append(ns) + + if not ns2remove and not ns2add: + if for_zone: + msg = "Subzone {f!r} has already the expected nameservers in zone {z!r}." + else: + msg = "Zone {z!r} has already the expected nameservers." + LOG.info(msg.format(f=for_zone, z=zone.name)) + return False + + LOG.debug("Nameservers to remove from zone {z!r}:\n{ns}".format( + z=zone.name, ns=pp(ns2remove))) + LOG.debug("Nameservers to add to zone {z!r}:\n{ns}".format( + z=zone.name, ns=pp(ns2add))) + + ns_ttl = None + if not new_ttl: + cur_rrset = zone.get_ns_rrset(for_zone=for_zone) + if cur_rrset: + ns_ttl = cur_rrset.ttl + else: + soa = zone.get_soa() + ns_ttl = soa.ttl + del soa + else: + ns_ttl = int(new_ttl) + if ns_ttl <= 0: + ns_ttl = 3600 + LOG.debug("TTL for NS records: {}.".format(ns_ttl)) + + rrset_name = zone.name.lower() + if for_zone: + rrset_name = for_zone.lower() + + records = [] + for ns in new_nameservers: + record = { + "name": rrset_name, + "type": "NS", + "content": ns, + "disabled": False, + "set-ptr": False, + } + records.append(record) + rrset = { + "name": rrset_name, + "type": "NS", + "ttl": ns_ttl, + "changetype": "REPLACE", + "records": records, + } + + if comment: + comment_rec = { + 'content': comment, + 'account': getpass.getuser(), + 'modified_at': int(time.time() + 0.5), + } + rrset['comments'] = [comment_rec] + + payload = {"rrsets": [rrset]} + + self.patch_zone(zone, payload) + + if do_serial: + self.increase_serial(zone.name) + + if do_notify: + self.notify_zone(zone) + + return True + + # ------------------------------------------------------------------------- + def notify_zone(self, zone): + + LOG.info("Notifying slaves of zone {!r} ...".format(zone.name)) + + path = "/servers/{}/zones/{}/notify".format(self.api_servername, zone.name) + return self.perform_request(path, 'PUT', '', may_simulate=True) + +# ============================================================================= + + +if __name__ == "__main__": + + pass + +# ============================================================================= + +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 list diff --git a/lib/pp_admintools/pidfile.py b/lib/pp_admintools/pidfile.py new file mode 100644 index 0000000..c21a088 --- /dev/null +++ b/lib/pp_admintools/pidfile.py @@ -0,0 +1,523 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@author: Frank Brehm +@contact: frank.brehm@pixelpark.com +@copyright: © 2018 by Frank Brehm, Berlin +@summary: A module for a pidfile object. + It provides methods to define, check, create + and remove a pidfile. +""" +from __future__ import absolute_import + +# Standard modules +import os +import sys +import logging + +import re +import signal +import errno + +# Third party modules +import six +from six import reraise + +# Own modules +from fb_tools.errors import ReadTimeoutError +from fb_tools.common import to_utf8 +from fb_tools.obj import FbBaseObjectError, FbBaseObject + +__version__ = '0.3.0' + +LOG = logging.getLogger(__name__) + +# ============================================================================= +class PidFileError(FbBaseObjectError): + """Base error class for all exceptions happened during + handling a pidfile.""" + + pass + + +# ============================================================================= +class InvalidPidFileError(PidFileError): + """An error class indicating, that the given pidfile is unusable""" + + def __init__(self, pidfile, reason=None): + """ + Constructor. + + @param pidfile: the filename of the invalid pidfile. + @type pidfile: str + @param reason: the reason, why the pidfile is invalid. + @type reason: str + + """ + + self.pidfile = pidfile + self.reason = reason + + # ------------------------------------------------------------------------- + def __str__(self): + """Typecasting into a string for error output.""" + + msg = None + if self.reason: + msg = "Invalid pidfile {!r} given: {}".format(self.pidfile, self.reason) + else: + msg = "Invalid pidfile {!r} given.".format(self.pidfile) + + return msg + +# ============================================================================= +class PidFileInUseError(PidFileError): + """ + An error class indicating, that the given pidfile is in use + by another application. + """ + + def __init__(self, pidfile, pid): + """ + Constructor. + + @param pidfile: the filename of the pidfile. + @type pidfile: str + @param pid: the PID of the process owning the pidfile + @type pid: int + + """ + + self.pidfile = pidfile + self.pid = pid + + # ------------------------------------------------------------------------- + def __str__(self): + """Typecasting into a string for error output.""" + + msg = "The pidfile {!r} is currently in use by the application with the PID {}.".format( + self.pidfile, self.pid) + + return msg + + +# ============================================================================= +class PidFile(FbBaseObject): + """ + Base class for a pidfile object. + """ + + open_args = {} + if six.PY3: + open_args = { + 'encoding': 'utf-8', + 'errors': 'surrogateescape', + } + + # ------------------------------------------------------------------------- + def __init__( + self, filename, auto_remove=True, appname=None, verbose=0, + version=__version__, base_dir=None, + initialized=False, simulate=False, timeout=10): + """ + Initialisation of the pidfile object. + + @raise ValueError: no filename was given + @raise PidFileError: on some errors. + + @param filename: the filename of the pidfile + @type filename: str + @param auto_remove: Remove the self created pidfile on destroying + the current object + @type auto_remove: bool + @param appname: name of the current running application + @type appname: str + @param verbose: verbose level + @type verbose: int + @param version: the version string of the current object or application + @type version: str + @param base_dir: the base directory of all operations + @type base_dir: str + @param initialized: initialisation is complete after __init__() + of this object + @type initialized: bool + @param simulate: simulation mode + @type simulate: bool + @param timeout: timeout in seconds for IO operations on pidfile + @type timeout: int + + @return: None + """ + + self._created = False + """ + @ivar: the pidfile was created by this current object + @type: bool + """ + + super(PidFile, self).__init__( + appname=appname, + verbose=verbose, + version=version, + base_dir=base_dir, + initialized=False, + ) + + if not filename: + raise ValueError('No filename given on initializing PidFile object.') + + self._filename = os.path.abspath(str(filename)) + """ + @ivar: The filename of the pidfile + @type: str + """ + + self._auto_remove = bool(auto_remove) + """ + @ivar: Remove the self created pidfile on destroying the current object + @type: bool + """ + + self._simulate = bool(simulate) + """ + @ivar: Simulation mode + @type: bool + """ + + self._timeout = int(timeout) + """ + @ivar: timeout in seconds for IO operations on pidfile + @type: int + """ + + # ----------------------------------------------------------- + @property + def filename(self): + """The filename of the pidfile.""" + return self._filename + + # ----------------------------------------------------------- + @property + def auto_remove(self): + """Remove the self created pidfile on destroying the current object.""" + return self._auto_remove + + @auto_remove.setter + def auto_remove(self, value): + self._auto_remove = bool(value) + + # ----------------------------------------------------------- + @property + def simulate(self): + """Simulation mode.""" + return self._simulate + + # ----------------------------------------------------------- + @property + def created(self): + """The pidfile was created by this current object.""" + return self._created + + # ----------------------------------------------------------- + @property + def timeout(self): + """The timeout in seconds for IO operations on pidfile.""" + return self._timeout + + # ----------------------------------------------------------- + @property + def parent_dir(self): + """The directory containing the pidfile.""" + return os.path.dirname(self.filename) + + # ------------------------------------------------------------------------- + def as_dict(self, short=True): + """ + Transforms the elements of the object into a dict + + @param short: don't include local properties in resulting dict. + @type short: bool + + @return: structure as dict + @rtype: dict + """ + + res = super(PidFile, self).as_dict(short=short) + res['filename'] = self.filename + res['auto_remove'] = self.auto_remove + res['simulate'] = self.simulate + res['created'] = self.created + res['timeout'] = self.timeout + res['parent_dir'] = self.parent_dir + res['open_args'] = self.open_args + + return res + + # ------------------------------------------------------------------------- + def __repr__(self): + """Typecasting into a string for reproduction.""" + + out = "<%s(" % (self.__class__.__name__) + + fields = [] + fields.append("filename=%r" % (self.filename)) + fields.append("auto_remove=%r" % (self.auto_remove)) + fields.append("appname=%r" % (self.appname)) + fields.append("verbose=%r" % (self.verbose)) + fields.append("base_dir=%r" % (self.base_dir)) + fields.append("initialized=%r" % (self.initialized)) + fields.append("simulate=%r" % (self.simulate)) + fields.append("timeout=%r" % (self.timeout)) + + out += ", ".join(fields) + ")>" + return out + + # ------------------------------------------------------------------------- + def __del__(self): + """Destructor. Removes the pidfile, if it was created by ourselfes.""" + + if not self.created: + return + + if not os.path.exists(self.filename): + if self.verbose > 3: + LOG.debug("Pidfile {!r} doesn't exists, not removing.".format(self.filename)) + return + + if not self.auto_remove: + if self.verbose > 3: + LOG.debug("Auto removing disabled, don't deleting {!r}.".format(self.filename)) + return + + if self.verbose > 1: + LOG.debug("Removing pidfile {!r} ...".format(self.filename)) + if self.simulate: + if self.verbose > 1: + LOG.debug("Just kidding ..") + return + try: + os.remove(self.filename) + except OSError as e: + LOG.err("Could not delete pidfile {!r}: {}".format(self.filename, e)) + except Exception as e: + self.handle_error(str(e), e.__class__.__name__, True) + + # ------------------------------------------------------------------------- + def create(self, pid=None): + """ + The main method of this class. It tries to write the PID of the process + into the pidfile. + + @param pid: the pid to write into the pidfile. If not given, the PID of + the current process will taken. + @type pid: int + + """ + + if pid: + pid = int(pid) + if pid <= 0: + msg = "Invalid PID {} for creating pidfile {!r} given.".format(pid, self.filename) + raise PidFileError(msg) + else: + pid = os.getpid() + + if self.check(): + + LOG.info("Deleting pidfile {!r} ...".format(self.filename)) + if self.simulate: + LOG.debug("Just kidding ..") + else: + try: + os.remove(self.filename) + except OSError as e: + raise InvalidPidFileError(self.filename, str(e)) + + if self.verbose > 1: + LOG.debug("Trying opening {!r} exclusive ...".format(self.filename)) + + if self.simulate: + LOG.debug("Simulation mode - don't real writing in {!r}.".format(self.filename)) + self._created = True + return + + fd = None + try: + fd = os.open( + self.filename, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + except OSError as e: + error_tuple = sys.exc_info() + msg = "Error on creating pidfile {!r}: {}".format(self.filename, e) + reraise(PidFileError, msg, error_tuple[2]) + + if self.verbose > 2: + LOG.debug("Writing {} into {!r} ...".format(pid, self.filename)) + + out = to_utf8("%d\n" % (pid)) + try: + os.write(fd, out) + finally: + os.close(fd) + + self._created = True + + # ------------------------------------------------------------------------- + def recreate(self, pid=None): + """ + Rewrites an even created pidfile with the current PID. + + @param pid: the pid to write into the pidfile. If not given, the PID of + the current process will taken. + @type pid: int + + """ + + if not self.created: + msg = "Calling recreate() on a not self created pidfile." + raise PidFileError(msg) + + if pid: + pid = int(pid) + if pid <= 0: + msg = "Invalid PID {} for creating pidfile {!r} given.".format(pid, self.filename) + raise PidFileError(msg) + else: + pid = os.getpid() + + if self.verbose > 1: + LOG.debug("Trying opening {!r} for recreate ...".format(self.filename)) + + if self.simulate: + LOG.debug("Simulation mode - don't real writing in {!r}.".format(self.filename)) + return + + fh = None + try: + fh = open(self.filename, 'w', **self.open_args) + except OSError as e: + error_tuple = sys.exc_info() + msg = "Error on recreating pidfile {!r}: {}".format(self.filename, e) + reraise(PidFileError, msg, error_tuple[2]) + + if self.verbose > 2: + LOG.debug("Writing {} into {!r} ...".format(pid, self.filename)) + + try: + fh.write("%d\n" % (pid)) + finally: + fh.close() + + # ------------------------------------------------------------------------- + def check(self): + """ + This methods checks the usability of the pidfile. + If the method doesn't raise an exception, the pidfile is usable. + + It returns, whether the pidfile exist and can be deleted or not. + + @raise InvalidPidFileError: if the pidfile is unusable + @raise PidFileInUseError: if the pidfile is in use by another application + @raise ReadTimeoutError: on timeout reading an existing pidfile + @raise OSError: on some other reasons, why the existing pidfile + couldn't be read + + @return: the pidfile exists, but can be deleted - or it doesn't + exists. + @rtype: bool + + """ + + if not os.path.exists(self.filename): + if not os.path.exists(self.parent_dir): + reason = "Pidfile parent directory {!r} doesn't exists.".format( + self.parent_dir) + raise InvalidPidFileError(self.filename, reason) + if not os.path.isdir(self.parent_dir): + reason = "Pidfile parent directory {!r} is not a directory.".format( + self.parent_dir) + raise InvalidPidFileError(self.filename, reason) + if not os.access(self.parent_dir, os.X_OK): + reason = "No write access to pidfile parent directory {!r}.".format( + self.parent_dir) + raise InvalidPidFileError(self.filename, reason) + + return False + + if not os.path.isfile(self.filename): + reason = "It is not a regular file." + raise InvalidPidFileError(self.filename, self.parent_dir) + + # --------- + def pidfile_read_alarm_caller(signum, sigframe): + """ + This nested function will be called in event of a timeout. + + @param signum: the signal number (POSIX) which happend + @type signum: int + @param sigframe: the frame of the signal + @type sigframe: object + """ + + return ReadTimeoutError(self.timeout, self.filename) + + if self.verbose > 1: + LOG.debug("Reading content of pidfile {!r} ...".format(self.filename)) + + signal.signal(signal.SIGALRM, pidfile_read_alarm_caller) + signal.alarm(self.timeout) + + content = '' + fh = None + + try: + fh = open(self.filename, 'r') + for line in fh.readlines(): + content += line + finally: + if fh: + fh.close() + signal.alarm(0) + + # Performing content of pidfile + + pid = None + line = content.strip() + match = re.search(r'^\s*(\d+)\s*$', line) + if match: + pid = int(match.group(1)) + else: + msg = "No useful information found in pidfile {!r}: {!r}".format(self.filename, line) + return True + + if self.verbose > 1: + LOG.debug("Trying check for process with PID {} ...".format(pid)) + + try: + os.kill(pid, 0) + except OSError as err: + if err.errno == errno.ESRCH: + LOG.info("Process with PID {} anonymous died.".format(pid)) + return True + elif err.errno == errno.EPERM: + error_tuple = sys.exc_info() + msg = "No permission to signal the process {} ...".format(pid) + reraise(PidFileError, msg, error_tuple[2]) + else: + error_tuple = sys.exc_info() + msg = "Got a {}: {}.".format(err.__class__.__name__, err) + reraise(PidFileError, msg, error_tuple[2]) + else: + raise PidFileInUseError(self.filename, pid) + + return False + + +# ============================================================================= + +if __name__ == "__main__": + + pass + +# ============================================================================= + +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 list -- 2.39.5