
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

"""
Implements cfn metadata handling

Not implemented yet:
    * command line args
      - placeholders are ignored
"""
import atexit
import contextlib
import errno
import functools
import grp
import json
import logging
import os
import os.path
import pwd
try:
    import rpmUtils.miscutils as rpmutils
    import rpmUtils.updates as rpmupdates
    rpmutils_present = True
except ImportError:
    rpmutils_present = False
import re
import shutil
import six
import six.moves.configparser as ConfigParser
import subprocess
import tempfile


# Override BOTO_CONFIG, which makes boto look only at the specified
# config file, instead of the default locations
os.environ['BOTO_CONFIG'] = '/var/lib/heat-cfntools/cfn-boto-cfg'
from boto import cloudformation


LOG = logging.getLogger(__name__)


def to_boolean(b):
    val = b.lower().strip() if isinstance(b, six.string_types) else b
    return val in [True, 'true', 'yes', '1', 1]


def parse_creds_file(path='/etc/cfn/cfn-credentials'):
    '''Parse the cfn credentials file.

    Default location is as specified, and it is expected to contain
    exactly two keys "AWSAccessKeyId" and "AWSSecretKey)
    The two keys are returned a dict (if found)
    '''
    creds = {'AWSAccessKeyId': None, 'AWSSecretKey': None}
    for line in open(path):
        for key in creds:
            match = re.match("^%s *= *(.*)$" % key, line)
            if match:
                creds[key] = match.group(1)
    return creds


class HupConfig(object):
    def __init__(self, fp_list):
        self.config = ConfigParser.SafeConfigParser()
        for fp in fp_list:
            self.config.readfp(fp)

        self.load_main_section()

        self.hooks = []
        for s in self.config.sections():
            if s != 'main':
                self.hooks.append(Hook(
                    s,
                    self.config.get(s, 'triggers'),
                    self.config.get(s, 'path'),
                    self.config.get(s, 'runas'),
                    self.config.get(s, 'action')))

    def load_main_section(self):
        # required values
        self.stack = self.config.get('main', 'stack')
        self.credential_file = self.config.get('main', 'credential-file')
        try:
            with open(self.credential_file) as f:
                self.credentials = f.read()
        except Exception:
            raise Exception("invalid credentials file %s" %
                            self.credential_file)

        # optional values
        try:
            self.region = self.config.get('main', 'region')
        except ConfigParser.NoOptionError:
            self.region = 'nova'

        try:
            self.interval = self.config.getint('main', 'interval')
        except ConfigParser.NoOptionError:
            self.interval = 10

    def __str__(self):
        return '{stack: %s, credential_file: %s, region: %s, interval:%d}' % \
            (self.stack, self.credential_file, self.region, self.interval)

    def unique_resources_get(self):
        resources = []
        for h in self.hooks:
            r = h.resource_name_get()
            if r not in resources:
                resources.append(h.resource_name_get())
        return resources


class Hook(object):
    def __init__(self, name, triggers, path, runas, action):
        self.name = name
        self.triggers = triggers
        self.path = path
        self.runas = runas
        self.action = action

    def resource_name_get(self):
        sp = self.path.split('.')
        return sp[1]

    def event(self, ev_name, ev_object, ev_resource):
        if self.resource_name_get() == ev_resource and \
                ev_name in self.triggers:
            CommandRunner(self.action, shell=True).run(user=self.runas)
        else:
            LOG.debug('event: {%s, %s, %s} did not match %s' %
                      (ev_name, ev_object, ev_resource, self.__str__()))

    def __str__(self):
        return '{%s, %s, %s, %s, %s}' % \
            (self.name,
             self.triggers,
             self.path,
             self.runas,
             self.action)


class ControlledPrivilegesFailureException(Exception):
    pass


@contextlib.contextmanager
def controlled_privileges(user):
    orig_euid = None
    try:
        real = pwd.getpwnam(user)
        if os.geteuid() != real.pw_uid:
            orig_euid = os.geteuid()
            os.seteuid(real.pw_uid)
            LOG.debug("Privileges set for user %s" % user)
    except Exception as e:
        raise ControlledPrivilegesFailureException(e)

    try:
        yield
    finally:
        if orig_euid is not None:
            try:
                os.seteuid(orig_euid)
                LOG.debug("Original privileges restored.")
            except Exception as e:
                LOG.error("Error restoring privileges %s" % e)


class CommandRunner(object):
    """Helper class to run a command and store the output."""

    def __init__(self, command, shell=False, nextcommand=None):
        self._command = command
        self._shell = shell
        self._next = nextcommand
        self._stdout = None
        self._stderr = None
        self._status = None

    def __str__(self):
        s = "CommandRunner:"
        s += "\n\tcommand: %s" % self._command
        if self._status:
            s += "\n\tstatus: %s" % self.status
        if self._stdout:
            s += "\n\tstdout: %s" % self.stdout
        if self._stderr:
            s += "\n\tstderr: %s" % self.stderr
        return s

    def run(self, user='root', cwd=None, env=None):
        """Run the Command and return the output.

        Returns:
            self
        """
        LOG.debug("Running command: %s" % self._command)

        cmd = self._command
        shell = self._shell

        # Ensure commands that are given as string are run on shell
        assert isinstance(cmd, six.string_types) is bool(shell)

        try:
            with controlled_privileges(user):
                subproc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
                                           stderr=subprocess.PIPE, cwd=cwd,
                                           env=env, shell=shell)
                output = subproc.communicate()
                self._status = subproc.returncode
                self._stdout = output[0]
                self._stderr = output[1]
        except ControlledPrivilegesFailureException as e:
            LOG.error("Error setting privileges for user '%s': %s"
                      % (user, e))
            self._status = 126
            self._stderr = six.text_type(e)

        if self._status:
            LOG.debug("Return code of %d after executing: '%s'\n"
                      "stdout: '%s'\n"
                      "stderr: '%s'" % (self._status, cmd, self._stdout,
                                        self._stderr))

        if self._next:
            self._next.run()
        return self

    @property
    def stdout(self):
        return self._stdout

    @property
    def stderr(self):
        return self._stderr

    @property
    def status(self):
        return self._status


class RpmHelper(object):

    if rpmutils_present:
        _rpm_util = rpmupdates.Updates([], [])

    @classmethod
    def compare_rpm_versions(cls, v1, v2):
        """Compare two RPM version strings.

        Arguments:
            v1 -- a version string
            v2 -- a version string

        Returns:
            0 -- the versions are equal
            1 -- v1 is greater
           -1 -- v2 is greater
        """
        if v1 and v2:
            return rpmutils.compareVerOnly(v1, v2)
        elif v1:
            return 1
        elif v2:
            return -1
        else:
            return 0

    @classmethod
    def newest_rpm_version(cls, versions):
        """Returns the highest (newest) version from a list of versions.

        Arguments:
            versions -- A list of version strings
                        e.g., ['2.0', '2.2', '2.2-1.fc16', '2.2.22-1.fc16']
        """
        if versions:
            if isinstance(versions, six.string_types):
                return versions
            versions = sorted(versions, rpmutils.compareVerOnly,
                              reverse=True)
            return versions[0]
        else:
            return None

    @classmethod
    def rpm_package_version(cls, pkg):
        """Returns the version of an installed RPM.

        Arguments:
            pkg -- A package name
        """
        cmd = "rpm -q --queryformat '%{VERSION}-%{RELEASE}' %s" % pkg
        command = CommandRunner(cmd).run()
        return command.stdout

    @classmethod
    def rpm_package_installed(cls, pkg):
        """Indicates whether pkg is in rpm database.

        Arguments:
            pkg -- A package name (with optional version and release spec).
                   e.g., httpd
                   e.g., httpd-2.2.22
                   e.g., httpd-2.2.22-1.fc16
        """
        cmd = ['rpm', '-q', pkg]
        command = CommandRunner(cmd).run()
        return command.status == 0

    @classmethod
    def yum_package_available(cls, pkg):
        """Indicates whether pkg is available via yum.

        Arguments:
            pkg -- A package name (with optional version and release spec).
                   e.g., httpd
                   e.g., httpd-2.2.22
                   e.g., httpd-2.2.22-1.fc16
        """
        cmd = ['yum', '-y', '--showduplicates', 'list', 'available', pkg]
        command = CommandRunner(cmd).run()
        return command.status == 0

    @classmethod
    def dnf_package_available(cls, pkg):
        """Indicates whether pkg is available via dnf.

        Arguments:
            pkg -- A package name (with optional version and release spec).
                   e.g., httpd
                   e.g., httpd-2.2.22
                   e.g., httpd-2.2.22-1.fc21
        """
        cmd = ['dnf', '-y', '--showduplicates', 'list', 'available', pkg]
        command = CommandRunner(cmd).run()
        return command.status == 0

    @classmethod
    def zypper_package_available(cls, pkg):
        """Indicates whether pkg is available via zypper.

        Arguments:
            pkg -- A package name (with optional version and release spec).
                   e.g., httpd
                   e.g., httpd-2.2.22
                   e.g., httpd-2.2.22-1.fc16
        """
        cmd = ['zypper', '-n', '--no-refresh', 'search', pkg]
        command = CommandRunner(cmd).run()
        return command.status == 0

    @classmethod
    def install(cls, packages, rpms=True, zypper=False, dnf=False):
        """Installs (or upgrades) packages via RPM, yum, dnf, or zypper.

        Arguments:
            packages -- a list of packages to install
            rpms     -- if True:
                        * use RPM to install the packages
                        * packages must be a list of URLs to retrieve RPMs
                        if False:
                        * use Yum to install packages
                        * packages is a list of:
                          - pkg name (httpd), or
                          - pkg name with version spec (httpd-2.2.22), or
                          - pkg name with version-release spec
                            (httpd-2.2.22-1.fc16)
            zypper   -- if True:
                        * overrides use of yum, use zypper instead
            dnf      -- if True:
                        * overrides use of yum, use dnf instead
                        * packages must be in same format as yum pkg list
        """
        if rpms:
            cmd = ['rpm', '-U', '--force', '--nosignature']
        elif zypper:
            cmd = ['zypper', '-n', 'install']
        elif dnf:
            # use dnf --best to upgrade outdated-but-installed packages
            cmd = ['dnf', '-y', '--best', 'install']
        else:
            cmd = ['yum', '-y', 'install']
        cmd.extend(packages)
        LOG.info("Installing packages: %s" % cmd)
        command = CommandRunner(cmd).run()
        if command.status:
            LOG.warn("Failed to install packages: %s" % cmd)

    @classmethod
    def downgrade(cls, packages, rpms=True, zypper=False, dnf=False):
        """Downgrades a set of packages via RPM, yum, dnf, or zypper.

        Arguments:
            packages -- a list of packages to downgrade
            rpms     -- if True:
                        * use RPM to downgrade (replace) the packages
                        * packages must be a list of URLs to retrieve the RPMs
                        if False:
                        * use Yum to downgrade packages
                        * packages is a list of:
                          - pkg name with version spec (httpd-2.2.22), or
                          - pkg name with version-release spec
                            (httpd-2.2.22-1.fc16)
            dnf     -- if True:
                       * Use dnf instead of RPM/yum
        """
        if rpms:
            cls.install(packages)
        elif zypper:
            cmd = ['zypper', '-n', 'install', '--oldpackage']
            cmd.extend(packages)
            LOG.info("Downgrading packages: %s", cmd)
            command = CommandRunner(cmd).run()
            if command.status:
                LOG.warn("Failed to downgrade packages: %s" % cmd)
        elif dnf:
            cmd = ['dnf', '-y', 'downgrade']
            cmd.extend(packages)
            LOG.info("Downgrading packages: %s", cmd)
            command = CommandRunner(cmd).run()
            if command.status:
                LOG.warn("Failed to downgrade packages: %s" % cmd)
        else:
            cmd = ['yum', '-y', 'downgrade']
            cmd.extend(packages)
            LOG.info("Downgrading packages: %s" % cmd)
            command = CommandRunner(cmd).run()
            if command.status:
                LOG.warn("Failed to downgrade packages: %s" % cmd)


class PackagesHandler(object):
    _packages = {}

    _package_order = ["dpkg", "rpm", "apt", "yum", "dnf"]

    @staticmethod
    def _pkgsort(pkg1, pkg2):
        order = PackagesHandler._package_order
        p1_name = pkg1[0]
        p2_name = pkg2[0]
        if p1_name in order and p2_name in order:
            return cmp(order.index(p1_name), order.index(p2_name))
        elif p1_name in order:
            return -1
        elif p2_name in order:
            return 1
        else:
            return cmp(p1_name.lower(), p2_name.lower())

    def __init__(self, packages):
        self._packages = packages

    def _handle_gem_packages(self, packages):
        """very basic support for gems."""
        # TODO(asalkeld) support versions
        # -b == local & remote install
        # -y == install deps
        opts = ['-b', '-y']
        for pkg_name, versions in packages.items():
            if len(versions) > 0:
                cmd = ['gem', 'install'] + opts
                cmd.extend(['--version', versions[0], pkg_name])
                CommandRunner(cmd).run()
            else:
                cmd = ['gem', 'install'] + opts
                cmd.append(pkg_name)
                CommandRunner(cmd).run()

    def _handle_python_packages(self, packages):
        """very basic support for easy_install."""
        # TODO(asalkeld) support versions
        for pkg_name, versions in packages.items():
            cmd = ['easy_install', pkg_name]
            CommandRunner(cmd).run()

    def _handle_zypper_packages(self, packages):
        """Handle installation, upgrade, or downgrade of packages via yum.

        Arguments:
        packages -- a package entries map of the form:
                      "pkg_name" : "version",
                      "pkg_name" : ["v1", "v2"],
                      "pkg_name" : []

        For each package entry:
          * if no version is supplied and the package is already installed, do
            nothing
          * if no version is supplied and the package is _not_ already
            installed, install it
          * if a version string is supplied, and the package is already
            installed, determine whether to downgrade or upgrade (or do nothing
            if version matches installed package)
          * if a version array is supplied, choose the highest version from the
            array and follow same logic for version string above
        """
        # collect pkgs for batch processing at end
        installs = []
        downgrades = []
        for pkg_name, versions in packages.items():
            ver = RpmHelper.newest_rpm_version(versions)
            pkg = "%s-%s" % (pkg_name, ver) if ver else pkg_name
            if RpmHelper.rpm_package_installed(pkg):
                # FIXME:print non-error, but skipping pkg
                pass
            elif not RpmHelper.zypper_package_available(pkg):
                LOG.warn("Skipping package '%s' - unavailable via zypper", pkg)
            elif not ver:
                installs.append(pkg)
            else:
                current_ver = RpmHelper.rpm_package_version(pkg)
                rc = RpmHelper.compare_rpm_versions(current_ver, ver)
                if rc < 0:
                    installs.append(pkg)
                elif rc > 0:
                    downgrades.append(pkg)
        if installs:
            RpmHelper.install(installs, rpms=False, zypper=True)
        if downgrades:
            RpmHelper.downgrade(downgrades, zypper=True)

    def _handle_dnf_packages(self, packages):
        """Handle installation, upgrade, or downgrade of packages via dnf.

        Arguments:
        packages -- a package entries map of the form:
                      "pkg_name" : "version",
                      "pkg_name" : ["v1", "v2"],
                      "pkg_name" : []

        For each package entry:
          * if no version is supplied and the package is already installed, do
            nothing
          * if no version is supplied and the package is _not_ already
            installed, install it
          * if a version string is supplied, and the package is already
            installed, determine whether to downgrade or upgrade (or do nothing
            if version matches installed package)
          * if a version array is supplied, choose the highest version from the
            array and follow same logic for version string above
        """
        # collect pkgs for batch processing at end
        installs = []
        downgrades = []
        for pkg_name, versions in packages.items():
            ver = RpmHelper.newest_rpm_version(versions)
            pkg = "%s-%s" % (pkg_name, ver) if ver else pkg_name
            if RpmHelper.rpm_package_installed(pkg):
                # FIXME:print non-error, but skipping pkg
                pass
            elif not RpmHelper.dnf_package_available(pkg):
                LOG.warn("Skipping package '%s'. Not available via yum" % pkg)
            elif not ver:
                installs.append(pkg)
            else:
                current_ver = RpmHelper.rpm_package_version(pkg)
                rc = RpmHelper.compare_rpm_versions(current_ver, ver)
                if rc < 0:
                    installs.append(pkg)
                elif rc > 0:
                    downgrades.append(pkg)
        if installs:
            RpmHelper.install(installs, rpms=False, dnf=True)
        if downgrades:
            RpmHelper.downgrade(downgrades, rpms=False, dnf=True)

    def _handle_yum_packages(self, packages):
        """Handle installation, upgrade, or downgrade of packages via yum.

        Arguments:
        packages -- a package entries map of the form:
                      "pkg_name" : "version",
                      "pkg_name" : ["v1", "v2"],
                      "pkg_name" : []

        For each package entry:
          * if no version is supplied and the package is already installed, do
            nothing
          * if no version is supplied and the package is _not_ already
            installed, install it
          * if a version string is supplied, and the package is already
            installed, determine whether to downgrade or upgrade (or do nothing
            if version matches installed package)
          * if a version array is supplied, choose the highest version from the
            array and follow same logic for version string above
        """

        cmd = CommandRunner(['which', 'yum']).run()
        if cmd.status == 1:
            # yum not available, use DNF if available
            self._handle_dnf_packages(packages)
            return
        elif cmd.status == 127:
            # `which` command not found
            LOG.info("`which` not found. Using yum without checking if dnf "
                     "is available")

        # collect pkgs for batch processing at end
        installs = []
        downgrades = []
        for pkg_name, versions in packages.items():
            ver = RpmHelper.newest_rpm_version(versions)
            pkg = "%s-%s" % (pkg_name, ver) if ver else pkg_name
            if RpmHelper.rpm_package_installed(pkg):
                # FIXME:print non-error, but skipping pkg
                pass
            elif not RpmHelper.yum_package_available(pkg):
                LOG.warn("Skipping package '%s'. Not available via yum" % pkg)
            elif not ver:
                installs.append(pkg)
            else:
                current_ver = RpmHelper.rpm_package_version(pkg)
                rc = RpmHelper.compare_rpm_versions(current_ver, ver)
                if rc < 0:
                    installs.append(pkg)
                elif rc > 0:
                    downgrades.append(pkg)
        if installs:
            RpmHelper.install(installs, rpms=False)
        if downgrades:
            RpmHelper.downgrade(downgrades)

    def _handle_rpm_packages(self, packages):
        """Handle installation, upgrade, or downgrade of packages via rpm.

        Arguments:
        packages -- a package entries map of the form:
                      "pkg_name" : "url"

        For each package entry:
          * if the EXACT package is already installed, skip it
          * if a different version of the package is installed, overwrite it
          * if the package isn't installed, install it
        """
        #FIXME: handle rpm installs
        pass

    def _handle_apt_packages(self, packages):
        """very basic support for apt."""
        # TODO(asalkeld) support versions
        pkg_list = list(packages)

        env = {'DEBIAN_FRONTEND': 'noninteractive'}
        cmd = ['apt-get', '-y', 'install'] + pkg_list
        CommandRunner(cmd).run(env=env)

    # map of function pointers to handle different package managers
    _package_handlers = {"yum": _handle_yum_packages,
                         "dnf": _handle_dnf_packages,
                         "zypper": _handle_zypper_packages,
                         "rpm": _handle_rpm_packages,
                         "apt": _handle_apt_packages,
                         "rubygems": _handle_gem_packages,
                         "python": _handle_python_packages}

    def _package_handler(self, manager_name):
        handler = None
        if manager_name in self._package_handlers:
            handler = self._package_handlers[manager_name]
        return handler

    def apply_packages(self):
        """Install, upgrade, or downgrade packages listed.

        Each package is a dict containing package name and a list of versions
        Install order:
          * dpkg
          * rpm
          * apt
          * yum
          * dnf
        """
        if not self._packages:
            return
        try:
            packages = sorted(
                self._packages.items(), cmp=PackagesHandler._pkgsort)
        except TypeError:
            # On Python 3, we have to use key instead of cmp
            # This could also work on Python 2.7, but not on 2.6
            packages = sorted(
                self._packages.items(),
                key=functools.cmp_to_key(PackagesHandler._pkgsort))

        for manager, package_entries in packages:
            handler = self._package_handler(manager)
            if not handler:
                LOG.warn("Skipping invalid package type: %s" % manager)
            else:
                handler(self, package_entries)


class FilesHandler(object):
    def __init__(self, files):
        self._files = files

    def apply_files(self):
        if not self._files:
            return
        for fdest, meta in self._files.items():
            dest = fdest.encode()
            try:
                os.makedirs(os.path.dirname(dest))
            except OSError as e:
                if e.errno == errno.EEXIST:
                    LOG.debug(str(e))
                else:
                    LOG.exception(e)

            if 'content' in meta:
                if isinstance(meta['content'], six.string_types):
                    f = open(dest, 'w+')
                    f.write(meta['content'])
                    f.close()
                else:
                    f = open(dest, 'w+')
                    f.write(json.dumps(meta['content'], indent=4)
                     .encode('UTF-8'))
                    f.close()
            elif 'source' in meta:
                CommandRunner(['curl', '-o', dest, meta['source']]).run()
            else:
                LOG.error('%s %s' % (dest, str(meta)))
                continue

            uid = -1
            gid = -1
            if 'owner' in meta:
                try:
                    user_info = pwd.getpwnam(meta['owner'])
                    uid = user_info[2]
                except KeyError:
                    pass

            if 'group' in meta:
                try:
                    group_info = grp.getgrnam(meta['group'])
                    gid = group_info[2]
                except KeyError:
                    pass

            os.chown(dest, uid, gid)
            if 'mode' in meta:
                os.chmod(dest, int(meta['mode'], 8))


class SourcesHandler(object):
    '''tar, tar+gzip,tar+bz2 and zip.'''
    _sources = {}

    def __init__(self, sources):
        self._sources = sources

    def _url_to_tmp_filename(self, url):
        tempdir = tempfile.mkdtemp()
        atexit.register(lambda: shutil.rmtree(tempdir, True))
        name = os.path.basename(url)
        return os.path.join(tempdir, name)

    def _splitext(self, path):
        (r, ext) = os.path.splitext(path)
        return (r, ext.lower())

    def _github_ball_type(self, url):
        ext = ""
        if url.endswith('/'):
            url = url[0:-1]
        sp = url.split('/')
        if len(sp) > 2:
            http = sp[0].startswith('http')
            github = sp[2].endswith('github.com')
            btype = sp[-2]
            if http and github:
                if 'zipball' == btype:
                    ext = '.zip'
                elif 'tarball' == btype:
                    ext = '.tgz'
        return ext

    def _source_type(self, url):
        (r, ext) = self._splitext(url)
        if ext == '.gz':
            (r, ext2) = self._splitext(r)
            if ext2 == '.tar':
                ext = '.tgz'
        elif ext == '.bz2':
            (r, ext2) = self._splitext(r)
            if ext2 == '.tar':
                ext = '.tbz2'
        elif ext == "":
            ext = self._github_ball_type(url)

        return ext

    def _apply_source_cmd(self, dest, url):
        cmd = ""
        basename = os.path.basename(url)
        stype = self._source_type(url)
        if stype == '.tgz':
            cmd = "curl -s '%s' | gunzip | tar -xvf -" % url
        elif stype == '.tbz2':
            cmd = "curl -s '%s' | bunzip2 | tar -xvf -" % url
        elif stype == '.zip':
            tmp = self._url_to_tmp_filename(url)
            cmd = "curl -s -o '%s' '%s' && unzip -o '%s'" % (tmp, url, tmp)
        elif stype == '.tar':
            cmd = "curl -s '%s' | tar -xvf -" % url
        elif stype == '.gz':
            (r, ext) = self._splitext(basename)
            cmd = "curl -s '%s' | gunzip > '%s'" % (url, r)
        elif stype == '.bz2':
            (r, ext) = self._splitext(basename)
            cmd = "curl -s '%s' | bunzip2 > '%s'" % (url, r)

        if cmd != '':
            cmd = "mkdir -p '%s'; cd '%s'; %s" % (dest, dest, cmd)

        return cmd

    def _apply_source(self, dest, url):
        cmd = self._apply_source_cmd(dest, url)
        #FIXME bug 1498298
        if cmd != '':
            runner = CommandRunner(cmd, shell=True)
            runner.run()

    def apply_sources(self):
        if not self._sources:
            return
        for dest, url in self._sources.items():
            self._apply_source(dest, url)


class ServicesHandler(object):
    _services = {}

    def __init__(self, services, resource=None, hooks=None):
        self._services = services
        self.resource = resource
        self.hooks = hooks

    def _handle_sysv_command(self, service, command):
        if os.path.exists("/bin/systemctl"):
            service_exe = "/bin/systemctl"
            service = '%s.service' % service
            service_start = [service_exe, 'start', service]
            service_status = [service_exe, 'status', service]
            service_stop = [service_exe, 'stop', service]
        elif os.path.exists("/sbin/service"):
            service_exe = "/sbin/service"
            service_start = [service_exe, service, 'start']
            service_status = [service_exe, service, 'status']
            service_stop = [service_exe, service, 'stop']
        else:
            service_exe = "/usr/sbin/service"
            service_start = [service_exe, service, 'start']
            service_status = [service_exe, service, 'status']
            service_stop = [service_exe, service, 'stop']

        if os.path.exists("/bin/systemctl"):
            enable_exe = "/bin/systemctl"
            enable_on = [enable_exe, 'enable', service]
            enable_off = [enable_exe, 'disable', service]
        elif os.path.exists("/sbin/chkconfig"):
            enable_exe = "/sbin/chkconfig"
            enable_on = [enable_exe, service, 'on']
            enable_off = [enable_exe, service, 'off']

        else:
            enable_exe = "/usr/sbin/update-rc.d"
            enable_on = [enable_exe, service, 'enable']
            enable_off = [enable_exe, service, 'disable']

        cmd = None
        if "enable" == command:
            cmd = enable_on
        elif "disable" == command:
            cmd = enable_off
        elif "start" == command:
            cmd = service_start
        elif "stop" == command:
            cmd = service_stop
        elif "status" == command:
            cmd = service_status

        if cmd is not None:
            command = CommandRunner(cmd)
            command.run()
            return command
        else:
            LOG.error("Unknown sysv command %s" % command)

    def _initialize_service(self, handler, service, properties):
        if "enabled" in properties:
            enable = to_boolean(properties["enabled"])
            if enable:
                LOG.info("Enabling service %s" % service)
                handler(self, service, "enable")
            else:
                LOG.info("Disabling service %s" % service)
                handler(self, service, "disable")

        if "ensureRunning" in properties:
            ensure_running = to_boolean(properties["ensureRunning"])
            command = handler(self, service, "status")
            running = command.status == 0
            if ensure_running and not running:
                LOG.info("Starting service %s" % service)
                handler(self, service, "start")
            elif not ensure_running and running:
                LOG.info("Stopping service %s" % service)
                handler(self, service, "stop")

    def _monitor_service(self, handler, service, properties):
        if "ensureRunning" in properties:
            ensure_running = to_boolean(properties["ensureRunning"])
            command = handler(self, service, "status")
            running = command.status == 0
            if ensure_running and not running:
                LOG.warn("Restarting service %s" % service)
                start_cmd = handler(self, service, "start")
                if start_cmd.status != 0:
                    LOG.warning('Service %s did not start. STDERR: %s' %
                               (service, start_cmd.stderr))
                for h in self.hooks:
                    h.event('service.restarted', service, self.resource)

    def _monitor_services(self, handler, services):
        for service, properties in services.items():
            self._monitor_service(handler, service, properties)

    def _initialize_services(self, handler, services):
        for service, properties in services.items():
            self._initialize_service(handler, service, properties)

    # map of function pointers to various service handlers
    _service_handlers = {
        "sysvinit": _handle_sysv_command,
        "systemd": _handle_sysv_command
    }

    def _service_handler(self, manager_name):
        handler = None
        if manager_name in self._service_handlers:
            handler = self._service_handlers[manager_name]
        return handler

    def apply_services(self):
        """Starts, stops, enables, disables services."""
        if not self._services:
            return
        for manager, service_entries in self._services.items():
            handler = self._service_handler(manager)
            if not handler:
                LOG.warn("Skipping invalid service type: %s" % manager)
            else:
                self._initialize_services(handler, service_entries)

    def monitor_services(self):
        """Restarts failed services, and runs hooks."""
        if not self._services:
            return
        for manager, service_entries in self._services.items():
            handler = self._service_handler(manager)
            if not handler:
                LOG.warn("Skipping invalid service type: %s" % manager)
            else:
                self._monitor_services(handler, service_entries)


class ConfigsetsHandler(object):

    def __init__(self, configsets, selectedsets):
        self.configsets = configsets
        self.selectedsets = selectedsets

    def expand_sets(self, list, executionlist):
        for elem in list:
            if isinstance(elem, dict):
                dictkeys = elem.keys()
                if len(dictkeys) != 1 or dictkeys.pop() != 'ConfigSet':
                    raise Exception('invalid ConfigSets metadata')
                dictkey = elem.values().pop()
                try:
                    self.expand_sets(self.configsets[dictkey], executionlist)
                except KeyError:
                    raise Exception("Undefined ConfigSet '%s' referenced"
                                    % dictkey)
            else:
                executionlist.append(elem)

    def get_configsets(self):
        """Returns a list of Configsets to execute in template."""
        if not self.configsets:
            if self.selectedsets:
                raise Exception('Template has no configSets')
            return
        if not self.selectedsets:
            if 'default' not in self.configsets:
                raise Exception('Template has no default configSet, must'
                                ' specify')
            self.selectedsets = 'default'

        selectedlist = [x.strip() for x in self.selectedsets.split(',')]
        executionlist = []
        for item in selectedlist:
            if item not in self.configsets:
                raise Exception("Requested configSet '%s' not in configSets"
                                " section" % item)
            self.expand_sets(self.configsets[item], executionlist)
        if not executionlist:
            raise Exception(
                "Requested configSet %s empty?" % self.selectedsets)

        return executionlist


def metadata_server_port(
        datafile='/var/lib/heat-cfntools/cfn-metadata-server'):
    """Return the the metadata server port.

    Reads the :NNNN from the end of the URL in cfn-metadata-server
    """
    try:
        f = open(datafile)
        server_url = f.read().strip()
        f.close()
    except IOError:
        return None

    if len(server_url) < 1:
        return None

    if server_url[-1] == '/':
        server_url = server_url[:-1]

    try:
        return int(server_url.split(':')[-1])
    except ValueError:
        return None


class CommandsHandlerRunError(Exception):
    pass


class CommandsHandler(object):

    def __init__(self, commands):
        self.commands = commands

    def apply_commands(self):
        """Execute commands on the instance in alphabetical order by name."""
        if not self.commands:
            return
        for command_label in sorted(self.commands):
            LOG.debug("%s is being processed" % command_label)
            self._initialize_command(command_label,
                                     self.commands[command_label])

    def _initialize_command(self, command_label, properties):
        command_status = None
        cwd = None
        env = properties.get("env", None)

        if "cwd" in properties:
            cwd = os.path.expanduser(properties["cwd"])
            if not os.path.exists(cwd):
                LOG.error("%s has failed. " % command_label +
                          "%s path does not exist" % cwd)
                return

        if "test" in properties:
            test = CommandRunner(properties["test"], shell=True)
            test_status = test.run('root', cwd, env).status
            if test_status != 0:
                LOG.info("%s test returns false, skipping command"
                         % command_label)
                return
            else:
                LOG.debug("%s test returns true, proceeding" % command_label)

        if "command" in properties:
            try:
                command = properties["command"]
                shell = isinstance(command, six.string_types)
                command = CommandRunner(command, shell=shell)
                command.run('root', cwd, env)
                command_status = command.status
            except OSError as e:
                if e.errno == errno.EEXIST:
                    LOG.debug(str(e))
                else:
                    LOG.exception(e)
        else:
            LOG.error("%s has failed. " % command_label
                      + "'command' property missing")
            return

        if command_status == 0:
            LOG.info("%s has been successfully executed" % command_label)
        else:
            if "ignoreErrors" in properties and \
               to_boolean(properties["ignoreErrors"]):
                LOG.info("%s has failed (status=%d). Explicit ignoring"
                         % (command_label, command_status))
            else:
                raise CommandsHandlerRunError("%s has failed." % command_label)


class GroupsHandler(object):

    def __init__(self, groups):
        self.groups = groups

    def apply_groups(self):
        """Create Linux/UNIX groups and assign group IDs."""
        if not self.groups:
            return
        for group, properties in self.groups.items():
            LOG.debug("%s group is being created" % group)
            self._initialize_group(group, properties)

    def _initialize_group(self, group, properties):
        gid = properties.get("gid", None)
        cmd = ['groupadd', group]
        if gid is not None:
            cmd.extend(['--gid', str(gid)])

        command = CommandRunner(cmd)
        command.run()
        command_status = command.status

        if command_status == 0:
            LOG.info("%s has been successfully created" % group)
        elif command_status == 9:
            LOG.error("An error occured creating %s group : " %
                      group + "group name not unique")
        elif command_status == 4:
            LOG.error("An error occured creating %s group : " %
                      group + "GID not unique")
        elif command_status == 3:
            LOG.error("An error occured creating %s group : " %
                      group + "GID not valid")
        elif command_status == 2:
            LOG.error("An error occured creating %s group : " %
                      group + "Invalid syntax")
        else:
            LOG.error("An error occured creating %s group" % group)


class UsersHandler(object):

    def __init__(self, users):
        self.users = users

    def apply_users(self):
        """Create Linux/UNIX users and assign user IDs, groups and homedir."""
        if not self.users:
            return
        for user, properties in self.users.items():
            LOG.debug("%s user is being created" % user)
            self._initialize_user(user, properties)

    def _initialize_user(self, user, properties):
        uid = properties.get("uid", None)
        homeDir = properties.get("homeDir", None)

        cmd = ['useradd', user]

        if uid is not None:
            cmd.extend(['--uid', six.text_type(uid)])

        if homeDir is not None:
            cmd.extend(['--home', six.text_type(homeDir)])

        if "groups" in properties:
            groups = ','.join(properties["groups"])
            cmd.extend(['--groups', groups])

        #Users are created as non-interactive system users with a shell
        #of /sbin/nologin. This is by design and cannot be modified.
        cmd.extend(['--shell', '/sbin/nologin'])

        command = CommandRunner(cmd)
        command.run()
        command_status = command.status

        if command_status == 0:
            LOG.info("%s has been successfully created" % user)
        elif command_status == 9:
            LOG.error("An error occured creating %s user : " %
                      user + "user name not unique")
        elif command_status == 6:
            LOG.error("An error occured creating %s user : " %
                      user + "group does not exist")
        elif command_status == 4:
            LOG.error("An error occured creating %s user : " %
                      user + "UID not unique")
        elif command_status == 3:
            LOG.error("An error occured creating %s user : " %
                      user + "Invalid argument")
        elif command_status == 2:
            LOG.error("An error occured creating %s user : " %
                      user + "Invalid syntax")
        else:
            LOG.error("An error occured creating %s user" % user)


class MetadataServerConnectionError(Exception):
    pass


class Metadata(object):
    _metadata = None
    _init_key = "AWS::CloudFormation::Init"
    DEFAULT_PORT = 8000

    def __init__(self, stack, resource, access_key=None,
                 secret_key=None, credentials_file=None, region=None,
                 configsets=None):

        self.stack = stack
        self.resource = resource
        self.access_key = access_key
        self.secret_key = secret_key
        self.region = region
        self.credentials_file = credentials_file
        self.access_key = access_key
        self.secret_key = secret_key
        self.configsets = configsets

        # TODO(asalkeld) is this metadata for the local resource?
        self._is_local_metadata = True
        self._metadata = None
        self._has_changed = False

    def remote_metadata(self):
        """Connect to the metadata server and retreive the metadata."""

        if self.credentials_file:
            credentials = parse_creds_file(self.credentials_file)
            access_key = credentials['AWSAccessKeyId']
            secret_key = credentials['AWSSecretKey']
        elif self.access_key and self.secret_key:
            access_key = self.access_key
            secret_key = self.secret_key
        else:
            raise MetadataServerConnectionError("No credentials!")

        port = metadata_server_port() or self.DEFAULT_PORT

        client = cloudformation.CloudFormationConnection(
            aws_access_key_id=access_key,
            aws_secret_access_key=secret_key,
            is_secure=False, port=port,
            path="/v1", debug=0)

        res = client.describe_stack_resource(self.stack, self.resource)
        # Note pending upstream patch will make this response a
        # boto.cloudformation.stack.StackResourceDetail object
        # which aligns better with all the existing calls
        # see https://github.com/boto/boto/pull/857
        resource_detail = res['DescribeStackResourceResponse'][
            'DescribeStackResourceResult']['StackResourceDetail']
        return resource_detail['Metadata']

    def get_nova_meta(self,
                      cache_path='/var/lib/heat-cfntools/nova_meta.json'):
        """Get nova's meta_data.json and cache it.

        Since this is called repeatedly return the cached metadata,
        if we have it.
        """

        url = 'http://169.254.169.254/openstack/2012-08-10/meta_data.json'
        if not os.path.exists(cache_path):
            cmd = ['curl', '-o', cache_path, url]
            CommandRunner(cmd).run()
        try:
            with open(cache_path) as fd:
                try:
                    return json.load(fd)
                except ValueError:
                    pass
        except IOError:
            pass
        return None

    def get_instance_id(self):
        """Get the unique identifier for this server."""
        instance_id = None
        md = self.get_nova_meta()
        if md is not None:
            instance_id = md.get('uuid')
        return instance_id

    def get_tags(self):
        """Get the tags for this server."""
        tags = {}
        md = self.get_nova_meta()
        if md is not None:
            tags.update(md.get('meta', {}))
            tags['InstanceId'] = md['uuid']
        return tags

    def retrieve(
            self,
            meta_str=None,
            default_path='/var/lib/heat-cfntools/cfn-init-data',
            last_path='/var/cache/heat-cfntools/last_metadata'):
        """Read the metadata from the given filename or from the remote server.

           Returns:
               True -- success
              False -- error
        """
        if self.resource is not None:
            res_last_path = last_path + '_' + self.resource
        else:
            res_last_path = last_path

        if meta_str:
            self._data = meta_str
        else:
            try:
                self._data = self.remote_metadata()
            except MetadataServerConnectionError as ex:
                LOG.warn("Unable to retrieve remote metadata : %s" % str(ex))

                # If reading remote metadata fails, we fall-back on local files
                # in order to get the most up-to-date version, we try:
                # /var/cache/heat-cfntools/last_metadata, followed by
                # /var/lib/heat-cfntools/cfn-init-data
                # This should allow us to do the right thing both during the
                # first cfn-init run (when we only have cfn-init-data), and
                # in the event of a temporary interruption to connectivity
                # affecting cfn-hup, in which case we want to use the locally
                # cached metadata or the logic below could re-run a stale
                # cfn-init-data
                fd = None
                for filepath in [res_last_path, last_path, default_path]:
                    try:
                        fd = open(filepath)
                    except IOError:
                        LOG.warn("Unable to open local metadata : %s" %
                                 filepath)
                        continue
                    else:
                        LOG.info("Opened local metadata %s" % filepath)
                        break

                if fd:
                    self._data = fd.read()
                    fd.close()
                else:
                    LOG.error("Unable to read any valid metadata!")
                    return

        if isinstance(self._data, str):
            self._metadata = json.loads(self._data)
        else:
            self._metadata = self._data

        last_data = ""
        for metadata_file in [res_last_path, last_path]:
            try:
                with open(metadata_file) as lm:
                    try:
                        last_data = json.load(lm)
                    except ValueError:
                        pass
                    lm.close()
            except IOError:
                LOG.warn("Unable to open local metadata : %s" %
                         metadata_file)
                continue

        if self._metadata != last_data:
            self._has_changed = True

        # if cache dir does not exist try to create it
        cache_dir = os.path.dirname(last_path)
        if not os.path.isdir(cache_dir):
            try:
                os.makedirs(cache_dir, mode=0o700)
            except IOError as e:
                LOG.warn('could not create metadata cache dir %s [%s]' %
                         (cache_dir, e))
                return
        # save current metadata to file
        tmp_dir = os.path.dirname(last_path)
        with tempfile.NamedTemporaryFile(dir=tmp_dir,
                                         mode='wb',
                                         delete=False) as cf:
            os.chmod(cf.name, 0o600)
            cf.write(json.dumps(self._metadata).encode('UTF-8'))
            os.rename(cf.name, last_path)
            cf.close()
            if res_last_path != last_path:
                shutil.copy(last_path, res_last_path)

        return True

    def __str__(self):
        return json.dumps(self._metadata)

    def display(self, key=None):
        """Print the metadata to the standard output stream. By default the
        full metadata is displayed but the ouptut can be limited to a specific
        with the <key> argument.

        Arguments:
            key -- the metadata's key to display, nested keys can be specified
                   separating them by the dot character.
                        e.g., "foo.bar"
                   If the key contains a dot, it should be surrounded by single
                   quotes
                        e.g., "foo.'bar.1'"
        """
        if self._metadata is None:
            return

        if key is None:
            print(str(self))
            return

        value = None
        md = self._metadata
        while True:
            key_match = re.match(r'^(?:(?:\'([^\']+)\')|([^\.]+))(?:\.|$)',
                                 key)
            if not key_match:
                break

            k = key_match.group(1) or key_match.group(2)
            if isinstance(md, dict) and k in md:
                key = key.replace(key_match.group(), '')
                value = md = md[k]
            else:
                break

        if key != '':
            value = None

        if value is not None:
            print(json.dumps(value))

        return

    def _is_valid_metadata(self):
        """Should find the AWS::CloudFormation::Init json key."""
        is_valid = self._metadata and \
            self._init_key in self._metadata and \
            self._metadata[self._init_key]
        if is_valid:
            self._metadata = self._metadata[self._init_key]
        return is_valid

    def _process_config(self, config="config"):
        """Parse and process a config section.

          * packages
          * sources
          * groups
          * users
          * files
          * commands
          * services
        """

        try:
            self._config = self._metadata[config]
        except KeyError:
            raise Exception("Could not find '%s' set in template, may need to"
                            " specify another set." % config)
        PackagesHandler(self._config.get("packages")).apply_packages()
        SourcesHandler(self._config.get("sources")).apply_sources()
        GroupsHandler(self._config.get("groups")).apply_groups()
        UsersHandler(self._config.get("users")).apply_users()
        FilesHandler(self._config.get("files")).apply_files()
        CommandsHandler(self._config.get("commands")).apply_commands()
        ServicesHandler(self._config.get("services")).apply_services()

    def cfn_init(self):
        """Process the resource metadata."""
        if not self._is_valid_metadata():
            raise Exception("invalid metadata")
        else:
            executionlist = ConfigsetsHandler(self._metadata.get("configSets"),
                                              self.configsets).get_configsets()
            if not executionlist:
                self._process_config()
            else:
                for item in executionlist:
                    self._process_config(item)

    def cfn_hup(self, hooks):
        """Process the resource metadata."""
        if not self._is_valid_metadata():
            LOG.debug(
                'Metadata does not contain a %s section' % self._init_key)

        if self._is_local_metadata:
            self._config = self._metadata.get("config", {})
            s = self._config.get("services")
            sh = ServicesHandler(s, resource=self.resource, hooks=hooks)
            sh.monitor_services()

        if self._has_changed:
            for h in hooks:
                h.event('post.update', self.resource, self.resource)
