]> Frank Brehm's Git Trees - pixelpark/pp-admin-tools.git/commitdiff
Importing Python stuff from git@git.pixelpark.com:frabrehm/admin-tools.git
authorFrank Brehm <frank@brehm-online.com>
Fri, 26 Nov 2021 09:46:37 +0000 (10:46 +0100)
committerFrank Brehm <frank@brehm-online.com>
Fri, 26 Nov 2021 09:46:37 +0000 (10:46 +0100)
bin/dns-deploy-zones [changed mode: 0644->0755]
lib/pp_admintools/__init__.py [new file with mode: 0644]
lib/pp_admintools/cfg_app.py [new file with mode: 0644]
lib/pp_admintools/deploy_zones_from_pdns.py [new file with mode: 0644]
lib/pp_admintools/errors.py [new file with mode: 0644]
lib/pp_admintools/global_version.py [new file with mode: 0644]
lib/pp_admintools/mailaddress.py [new file with mode: 0644]
lib/pp_admintools/merge.py [new file with mode: 0644]
lib/pp_admintools/pdns_app.py [new file with mode: 0644]
lib/pp_admintools/pidfile.py [new file with mode: 0644]

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