#!/usr/bin/env python

# Copyright (c) 1999-2014, Juniper Networks Inc.
#               2014, Jeremy Schulman
#
# All rights reserved.
#
# License: Apache 2.0
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright
#   notice, this list of conditions and the following disclaimer.
#
# * Redistributions in binary form must reproduce the above copyright
#   notice, this list of conditions and the following disclaimer in the
#   documentation and/or other materials provided with the distribution.
#
# * Neither the name of the Juniper Networks nor the
#   names of its contributors may be used to endorse or promote products
#   derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY Juniper Networks, Inc. ''AS IS'' AND ANY
# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL Juniper Networks, Inc. BE LIABLE FOR ANY
# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

DOCUMENTATION = '''
---
module: junos_install_config
author: Jeremy Schulman, Juniper Networks
version_added: "1.0.0"
short_description: Load a configuration file or snippet onto a device running Junos OS.
description:
    - Load a complete Junos OS configuration (overwrite) or merge a configuration
      snippet onto a device running Junos OS and commit it. The default behavior
      is to perform a B(load merge) operation (overwrite='no'). This module
      performs an atomic lock/edit/unlock. If the process fails at any step, then
      all configuration changes are discarded. You can load the configuration using
      either NETCONF or the CONSOLE port. Specify the I(console) option to use the
      CONSOLE port.

    - You provide the configuration data in a file. Supported formats when using
      NETCONF include ASCII text, Junos XML elements, and Junos OS B(set) commands.
      Configurations performed through the console must only use ASCII text formatting.
requirements:
    - junos-eznc >= 2.1.1
    - junos-netconify >= 1.0.1, when using the I(console) option
options:
    host:
        description:
            - Set to {{ inventory_hostname }}
        required: true
    user:
        description:
            - Login username
        required: false
        default: $USER
    passwd:
        description:
            - Login password
        required: false
        default: assumes ssh-key active
    file:
        description:
            - Path to the file containing the Junos OS configuration data.
              If the file has a C(*.conf) extension, the content is treated
              as text format. If the file has a C(*.xml) extension, the
              content is treated as XML format. If the file has a C(*.set)
              extension, the content is treated as Junos OS B(set)
              commands.
        required: true
    overwrite:
        description:
            - Specify whether the configuration I(file) completely replaces
              the existing configuration.
        required: false
        default: no
        choices: ['true','false','yes','no']
    update:
        description:
            - If set to ``True`` Compare a complete loaded configuration against
              the candidate configuration. For each hierarchy level or
              configuration object that is different in the two configurations,
              the version in the loaded configuration replaces the version in the
              candidate configuration. When the configuration is later committed,
              only system processes that are affected by the changed configuration
              elements parse the new configuration. This action is supported from
              PyEZ 2.1
        required: false
        default: no
        choices: ['true','false','yes','no']
    replace:
        description:
            - Specify whether the configuration I(file) uses "replace:" statements.
              (NETCONF only) B(NOT) compatible with B(set) format
        required: false
        default: no
        choices: ['true','false','yes','no']
    timeout:
        description:
            - Extend the NETCONF RPC timeout beyond the default value of
              30 seconds. Set this value to accommodate configuration
              changes (commits) that might take longer than the default
              timeout interval.
        required: false
        default: "0"
    logfile:
        description:
            - Path on the local server where the progress status is logged
              for debugging purposes
        required: false
        default: None
    diffs_file:
        description:
            - Path to the file where any diffs will be written
        required: false
        default: None
    console:
        description:
            - Port configuration, per the B(netconify) utility
        required: false
        default: None
    savedir:
        description:
            - Path to the local server directory where device facts and
              inventory files will be stored. This option is used only
              with the I(console) option.
              Refer to the B(netconify) utility for details.
        required: false
        default: None
    comment:
        description:
            - Provide a comment to the commit of the configuration
        required: false
        default: None
    port:
        description:
            - port number to use when connecting to the device
        required: false
        default: 830
    ssh_private_key_file:
        description:
            - This can be used if you need to provide a private key rather than
              loading the key into the ssh-key-ring/environment.  if your
              ssh-key requires a password, then you must provide it via
              **passwd**
        required: false
        default: None
    mode:
        description:
            - mode of console connection (telnet/serial). If mode is not
              provided SSH connection is used.
        required: false
        default: None
    confirm:
        description:
            - Provide a confirm in minutes to the commit of the configuration
        required: false
        default: None
    check_commit:
        description:
            - Specify whether the configuration will be commit-checked or not.
              Set to false, outputs similarly to the "show | compare" functionality,
              while true returns the "commit check" result.
        required: false
        default: yes
        choices: ['true','false','yes','no']
    ignore_warning:
        description:
            - A boolean, string or list of string. If the value is True, it
              will ignore all warnings regardless of the warning message. If
              the value is a string, it will ignore warning(s) if the message
              of each warning matches the string. If the value is a list of
              strings, ignore warning(s) if the message of each warning matches
              at least one of the strings in the list.
        required: false
        default: None
    check_commit_wait:
        description:
            - Set to number of seconds to wait between check and commit.
        required: false
        default: None
        choices: [1 - 4]
'''

EXAMPLES = '''
# load merge a change to the Junos OS configuration using NETCONF

- junos_install_config:
    host={{ inventory_hostname }}
    file=banner.conf

# load overwrite a new Junos OS configuration using the CONSOLE port

- junos_install_config:
    host={{ inventory_hostname }}
    console="--telnet={{TERMSERV}},{{TERMSERV_PORT}}"
    file=default_new_switch.conf
    overwrite=yes

# load merge a change to the Junos OS configuration using NETCONF and supplying a commit log message
- junos_install_config:
    host={{ inventory_hostname }}
    file=banner.conf
    comment="configured by ansible"

# load replace a change to the Junos OS configuration using NETCONF
- junos_install_config:
    host={{ inventory_hostname }}
    file=snmp.conf
    replace=yes

# Install/load config using console server connection using PyEZ >= 2.0

- junos_install_config:
    host={{ inventory_hostname }}
    port=7016
    mode='telnet'
    file=default_new_switch.conf
'''

import logging
from os.path import isfile
import os
from distutils.version import LooseVersion
import re
import ast

from ansible.module_utils.basic import *


def _load_via_netconf(module):
    args = module.params

    try:
        from jnpr.junos import Device
        from jnpr.junos.exception import LockError, UnlockError, \
            ConfigLoadError, CommitError
        from jnpr.junos.utils.config import Config
        from jnpr.junos.version import VERSION
        if not LooseVersion(VERSION) >= LooseVersion('2.1.1'):
            module.fail_json(msg='junos-eznc >= 2.1.1 is required for this module')
    except ImportError as ex:
        module.fail_json(msg='ImportError: %s' % ex.message)

    logfile = args['logfile']
    if logfile is not None:
        logging.basicConfig(filename=logfile, level=logging.INFO,
                            format='%(asctime)s:%(name)s:%(message)s')
        logging.getLogger().name = 'CONFIG:' + args['host']

    in_check_mode = module.check_mode
    check_commit = module.boolean(module.params['check_commit'])
    overwrite = module.boolean(module.params['overwrite'])
    replace = module.boolean(module.params['replace'])
    update = module.boolean(module.params['update'])

    actions = filter(lambda item: module.params.get(item, False),
                     ('overwrite', 'replace', 'update'))
    if len(list(actions)) >= 2:
        msg = 'action can be only one among %s' % ', '.join(actions)
        logging.error(msg)
        module.fail_json(msg=msg)

    if str(args['ignore_warning']).lower() in BOOLEANS:
        args['ignore_warning'] = module.boolean(module.params['ignore_warning'])
    elif isinstance(args['ignore_warning'], str) and \
            re.search('\[.*\]', args['ignore_warning']):
        args['ignore_warning'] = ast.literal_eval(args['ignore_warning'])

    logging.info("connecting to host: {0}@{1}:{2}".format(args['user'],
                                                          args['host'],
                                                          args['port']))

    try:
        dev = Device(args['host'], user=args['user'], password=args['passwd'],
                     port=args['port'],
                     ssh_private_key_file=args['ssh_private_key_file'],
                     mode=args['mode'],
                     gather_facts=False)
        dev.open()
    except Exception as err:
        msg = 'unable to connect to {0}: {1}'.format(args['host'], str(err))
        logging.error(msg)
        module.fail_json(msg=msg)
        return

    timeout = int(args['timeout'])
    if timeout > 0:
        dev.timeout = timeout

    cu = Config(dev)

    results = {}

    file_path = module.params['file']
    file_path = os.path.abspath(file_path)

    results['file'] = file_path
    results['changed'] = False

    logging.info("pushing file: {0}".format(file_path))
    try:
        logging.info("taking lock")
        cu.lock()

    except LockError:
        msg = "Unable to lock configuration"
        logging.error(msg)
        module.fail_json(msg=msg)

    logging.info("loading config")
    load_args = {}
    if args['ignore_warning'] is not None:
        load_args = {'ignore_warning': args['ignore_warning']}
    try:
        # load the config.  the cu.load will raise
        # an exception if there is even a warning.
        # so we want to avoid that condition.
        load_args.update({'path': file_path})
        if replace is True:
            load_args['merge'] = False
        elif overwrite is True:
            load_args['overwrite'] = True
        elif update is True:
            load_args['update'] = True
        elif overwrite is False:
            load_args['merge'] = True
        cu.load(**load_args)
    except (ValueError, ConfigLoadError, Exception) as err:
        msg = "Unable to load config: {0}".format(err)
        logging.error(msg)
        try:
            logging.info("unlocking")
            cu.unlock()
        except UnlockError as err:
            logging.error("Unable to unlock config: {0}".format(err))
        dev.close()
        module.fail_json(msg=msg)

    diff = cu.diff()

    if diff is not None:
        results['changed'] = True
        if getattr(module,'_diff',False) is True:
            results['diff'] = dict(
                prepared = diff
            )
        diffs_file = args['diffs_file']
        if diffs_file is not None:
            try:
                f = open(diffs_file, 'w')
                f.write(diff.encode('utf-8', 'ignore'))
                f.close()
            except IOError as (errno, strerror):
                msg = "Problem with diffs_file {0}: ".format(diffs_file)
                msg += "I/O Error: ({0}): {1}".format(errno, strerror)
                logging.error(msg)
                module.fail_json(msg=msg)
            except Exception as err:
                msg = "Problem with diffs_file {0}: ".format(diffs_file)
                msg += "Unexpected error: {0}".format(err)
                logging.error(msg)
                module.fail_json(msg=msg)

        if check_commit:
            try:
                logging.info("doing a commit-check, please be patient")
                cu.commit_check()
                if (in_check_mode):
                    logging.info("Ansible check mode complete")
                    module.exit_json(**results)
                else:
                    logging.info("committing change, please be patient")
                    opts = {}
                    if args['ignore_warning'] is not None:
                        opts = {'ignore_warning': args['ignore_warning']}
                    if args['comment'] is not None:
                        opts['comment'] = args['comment']
                    if args['confirm'] is not None:
                        opts['confirm'] = args['confirm']

                    if args['check_commit_wait']:
                       check_commit_wait = int(args['check_commit_wait'])
                       if 1 <= check_commit_wait <= 4:
                          time.sleep(check_commit_wait)

                    cu.commit(**opts)

            except CommitError as err:
                msg = "Unable to commit configuration: {0}".format(err)
                logging.error(msg)
                try:
                    logging.info("unlocking")
                    cu.unlock()
                except UnlockError as err:
                    logging.error("Unable to unlock config: {0}".format(err))
                dev.close()
                module.fail_json(msg=msg)

    try:
        logging.info("unlocking")
        cu.unlock()
    except UnlockError as err:
        logging.error("Unable to unlock config: {0}".format(err))

    logging.info("change completed")

    dev.close()
    module.exit_json(**results)


def _load_via_console(module):
    try:
        from netconify.cmdo import netconifyCmdo
        from netconify.constants import version
        if not LooseVersion(version) >= LooseVersion('1.0.1'):
            module.fail_json(msg='junos-netconify >= 1.0.1 is required for this module')
    except ImportError:
        module.fail_json(msg='junos-netconify >= 1.0.1 is required for this module')

    m_args = module.params

    c_args = []
    c_args.append(m_args['console'])
    c_args.append('--file=' + m_args['file'])
    if m_args['savedir'] is not None:
        c_args.append('--savedir=' + m_args['savedir'])
    c_args.append('--user=' + m_args['user'])
    if m_args['passwd'] is not None:
        c_args.append('--passwd=' + m_args['passwd'])

    # the default mode for loading a config via the console
    # is to load-overwrite.  So we need to check the module
    # option and set the "--merge" option if overwrite is False

    overwrite = module.boolean(module.params['overwrite'])
    if overwrite is False:
        c_args.append('--merge')

    c_args.append(m_args['host'])

    logfile = m_args['logfile']
    if logfile is not None:
        logging.basicConfig(filename=logfile, level=logging.INFO,
                            format='%(asctime)s:%(name)s:%(message)s')
        logging.getLogger().name = 'NETCONIFY:' + m_args['host']

        def log_notify(self, event, message):
            logging.info("%s:%s" % (event, message))
        use_notifier = log_notify
    else:
        def silent_notify(self, event, message):
            pass
        use_notifier = silent_notify

    try:
        nc = netconifyCmdo(notify=use_notifier)
        c_results = nc.run(c_args)
    except Exception as err:
        module.fail_json(msg=str(err))
    m_results = dict(changed=c_results['changed'])
    if c_results['failed'] is True:
        module.fail_json(msg=c_results['errmsg'])
    else:
        module.exit_json(**m_results)

# ---------------------------------------------------------------------------
# MAIN
# ---------------------------------------------------------------------------


def main():
    module = AnsibleModule(
        argument_spec=dict(
            host=dict(required=True),
            user=dict(required=False, default=os.getenv('USER')),
            passwd=dict(required=False, default=None, no_log=True),
            console=dict(required=False, default=None),
            file=dict(required=True),
            overwrite=dict(required=False, type='bool', choices=BOOLEANS, default=False),
            update=dict(required=False, type='bool', choices=BOOLEANS, default=False),
            replace=dict(required=False, type='bool', choices=BOOLEANS, default=False),
            logfile=dict(required=False, default=None),
            diffs_file=dict(required=False, default=None),
            savedir=dict(required=False),
            timeout=dict(required=False, default=0),
            comment=dict(required=False, default=None),
            port=dict(required=False, default=830),
            ssh_private_key_file=dict(required=False, default=None),
            mode=dict(required=False, default=None),
            confirm=dict(required=False, default=None),
            check_commit=dict(required=False, type='bool', choices=BOOLEANS, default=True),
            ignore_warning=dict(required=False, default=None),
            check_commit_wait=dict(required=False, default=None)
        ),
        supports_check_mode=True)

    args = module.params

    # ------------------------------
    # make sure file actually exists
    # ------------------------------

    if not isfile(args['file']):
        module.fail_json(msg="file not found: {0}".format(args['file']))
        return

    _ldr = _load_via_netconf if args['console'] is None else _load_via_console
    _ldr(module)

if __name__ == '__main__':
    main()
