--- /dev/null
+#!/bin/env python3
+# -*- coding: utf-8 -*-
+
+__version__ = '0.2.0'
+
+# vim: ts=4 et list
--- /dev/null
+#!/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 <WORKDIR>/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 <WORKDIR>/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
--- /dev/null
+#!/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
--- /dev/null
+#!/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
--- /dev/null
+#!/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 <frank.brehm@pixelpark.com>'
+__contact__ = 'frank.brehm@pixelpark.com'
+__version__ = '0.8.0'
+__license__ = 'LGPL3+'
+
+# vim: fileencoding=utf-8 filetype=python ts=4
--- /dev/null
+#!/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
--- /dev/null
+#!/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
--- /dev/null
+#!/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
--- /dev/null
+#!/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