# Copyright 2014-2015 Canonical Limited.
#
# 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.

# Easy file synchronization among peer units using ssh + unison.
#
# For the -joined, -changed, and -departed peer relations, add a call to
# ssh_authorized_peers() describing the peer relation and the desired
# user + group.  After all peer relations have settled, all hosts should
# be able to connect to on another via key auth'd ssh as the specified user.
#
# Other hooks are then free to synchronize files and directories using
# sync_to_peers().
#
# For a peer relation named 'cluster', for example:
#
# cluster-relation-joined:
# ...
# ssh_authorized_peers(peer_interface='cluster',
#                      user='juju_ssh', group='juju_ssh',
#                      ensure_local_user=True)
# ...
#
# cluster-relation-changed:
# ...
# ssh_authorized_peers(peer_interface='cluster',
#                      user='juju_ssh', group='juju_ssh',
#                      ensure_local_user=True)
# ...
#
# cluster-relation-departed:
# ...
# ssh_authorized_peers(peer_interface='cluster',
#                      user='juju_ssh', group='juju_ssh',
#                      ensure_local_user=True)
# ...
#
# Hooks are now free to sync files as easily as:
#
# files = ['/etc/fstab', '/etc/apt.conf.d/']
# sync_to_peers(peer_interface='cluster',
#                user='juju_ssh, paths=[files])
#
# It is assumed the charm itself has setup permissions on each unit
# such that 'juju_ssh' has read + write permissions.  Also assumed
# that the calling charm takes care of leader delegation.
#
# Additionally files can be synchronized only to an specific unit:
# sync_to_peer(slave_address, user='juju_ssh',
#              paths=[files], verbose=False)

import os
import pwd

from copy import copy
from subprocess import check_call, check_output

from charmhelpers.core.host import (
    adduser,
    add_user_to_group,
    pwgen,
    remove_password_expiry,
)

from charmhelpers.core.hookenv import (
    log,
    hook_name,
    relation_ids,
    related_units,
    relation_set,
    relation_get,
    unit_private_ip,
    INFO,
    ERROR,
)

BASE_CMD = ['unison', '-auto', '-batch=true', '-confirmbigdel=false',
            '-fastcheck=true', '-group=false', '-owner=false',
            '-prefer=newer', '-times=true']


def get_homedir(user):
    try:
        user = pwd.getpwnam(user)
        return user.pw_dir
    except KeyError:
        log('Could not get homedir for user %s: user exists?' % (user), ERROR)
        raise Exception


def create_private_key(user, priv_key_path, key_type='rsa'):
    types_bits = {
        'rsa': '2048',
        'ecdsa': '521',
    }
    if key_type not in types_bits:
        log('Unknown ssh key type {}, using rsa'.format(key_type), ERROR)
        key_type = 'rsa'
    if not os.path.isfile(priv_key_path):
        log('Generating new SSH key for user %s.' % user)
        cmd = ['ssh-keygen', '-q', '-N', '', '-t', key_type,
               '-b', types_bits[key_type], '-f', priv_key_path]
        check_call(cmd)
    else:
        log('SSH key already exists at %s.' % priv_key_path)
    os.chown(priv_key_path, pwd.getpwnam(user).pw_uid, -1)
    os.chmod(priv_key_path, 0o600)


def create_public_key(user, priv_key_path, pub_key_path):
    if not os.path.isfile(pub_key_path):
        log('Generating missing ssh public key @ %s.' % pub_key_path)
        cmd = ['ssh-keygen', '-y', '-f', priv_key_path]
        p = check_output(cmd).strip()
        with open(pub_key_path, 'wb') as out:
            out.write(p)
    os.chown(pub_key_path, pwd.getpwnam(user).pw_uid, -1)


def get_keypair(user):
    home_dir = get_homedir(user)
    ssh_dir = os.path.join(home_dir, '.ssh')
    priv_key = os.path.join(ssh_dir, 'id_rsa')
    pub_key = '%s.pub' % priv_key

    if not os.path.isdir(ssh_dir):
        os.mkdir(ssh_dir)
        check_call(['chown', '-R', user, ssh_dir])

    create_private_key(user, priv_key)
    create_public_key(user, priv_key, pub_key)

    with open(priv_key, 'r') as p:
        _priv = p.read().strip()

    with open(pub_key, 'r') as p:
        _pub = p.read().strip()

    return (_priv, _pub)


def write_authorized_keys(user, keys):
    home_dir = get_homedir(user)
    ssh_dir = os.path.join(home_dir, '.ssh')
    auth_keys = os.path.join(ssh_dir, 'authorized_keys')
    log('Syncing authorized_keys @ %s.' % auth_keys)
    with open(auth_keys, 'w') as out:
        for k in keys:
            out.write('%s\n' % k)
    os.chown(auth_keys, pwd.getpwnam(user).pw_uid, -1)


def write_known_hosts(user, hosts):
    home_dir = get_homedir(user)
    ssh_dir = os.path.join(home_dir, '.ssh')
    known_hosts = os.path.join(ssh_dir, 'known_hosts')
    khosts = []
    for host in hosts:
        cmd = ['ssh-keyscan', host]
        remote_key = check_output(cmd, universal_newlines=True).strip()
        khosts.append(remote_key)
    log('Syncing known_hosts @ %s.' % known_hosts)
    with open(known_hosts, 'w') as out:
        for host in khosts:
            out.write('%s\n' % host)
    os.chown(known_hosts, pwd.getpwnam(user).pw_uid, -1)


def ensure_user(user, group=None):
    adduser(user, pwgen())
    if group:
        add_user_to_group(user, group)
    # Remove password expiry (Bug #1686085)
    remove_password_expiry(user)


def ssh_authorized_peers(peer_interface, user, group=None,
                         ensure_local_user=False):
    """
    Main setup function, should be called from both peer -changed and -joined
    hooks with the same parameters.
    """
    if ensure_local_user:
        ensure_user(user, group)
    priv_key, pub_key = get_keypair(user)
    hook = hook_name()
    if hook == '%s-relation-joined' % peer_interface:
        relation_set(ssh_pub_key=pub_key)
    elif hook == '%s-relation-changed' % peer_interface or \
            hook == '%s-relation-departed' % peer_interface:
        hosts = []
        keys = []

        for r_id in relation_ids(peer_interface):
            for unit in related_units(r_id):
                ssh_pub_key = relation_get('ssh_pub_key',
                                           rid=r_id,
                                           unit=unit)
                priv_addr = relation_get('private-address',
                                         rid=r_id,
                                         unit=unit)
                if ssh_pub_key:
                    keys.append(ssh_pub_key)
                    hosts.append(priv_addr)
                else:
                    log('ssh_authorized_peers(): ssh_pub_key '
                        'missing for unit %s, skipping.' % unit)
        write_authorized_keys(user, keys)
        write_known_hosts(user, hosts)
        authed_hosts = ':'.join(hosts)
        relation_set(ssh_authorized_hosts=authed_hosts)


def _run_as_user(user, gid=None):
    try:
        user = pwd.getpwnam(user)
    except KeyError:
        log('Invalid user: %s' % user)
        raise Exception
    uid = user.pw_uid
    gid = gid or user.pw_gid
    os.environ['HOME'] = user.pw_dir

    def _inner():
        os.setgid(gid)
        os.setuid(uid)
    return _inner


def run_as_user(user, cmd, gid=None):
    return check_output(cmd, preexec_fn=_run_as_user(user, gid), cwd='/')


def collect_authed_hosts(peer_interface):
    '''Iterate through the units on peer interface to find all that
    have the calling host in its authorized hosts list'''
    hosts = []
    for r_id in (relation_ids(peer_interface) or []):
        for unit in related_units(r_id):
            private_addr = relation_get('private-address',
                                        rid=r_id, unit=unit)
            authed_hosts = relation_get('ssh_authorized_hosts',
                                        rid=r_id, unit=unit)

            if not authed_hosts:
                log('Peer %s has not authorized *any* hosts yet, skipping.' %
                    (unit), level=INFO)
                continue

            if unit_private_ip() in authed_hosts.split(':'):
                hosts.append(private_addr)
            else:
                log('Peer %s has not authorized *this* host yet, skipping.' %
                    (unit), level=INFO)
    return hosts


def sync_path_to_host(path, host, user, verbose=False, cmd=None, gid=None,
                      fatal=False):
    """Sync path to an specific peer host

    Propagates exception if operation fails and fatal=True.
    """
    cmd = cmd or copy(BASE_CMD)
    if not verbose:
        cmd.append('-silent')

    # removing trailing slash from directory paths, unison
    # doesn't like these.
    if path.endswith('/'):
        path = path[:(len(path) - 1)]

    cmd = cmd + [path, 'ssh://%s@%s/%s' % (user, host, path)]

    try:
        log('Syncing local path %s to %s@%s:%s' % (path, user, host, path))
        run_as_user(user, cmd, gid)
    except Exception:
        log('Error syncing remote files')
        if fatal:
            raise


def sync_to_peer(host, user, paths=None, verbose=False, cmd=None, gid=None,
                 fatal=False):
    """Sync paths to an specific peer host

    Propagates exception if any operation fails and fatal=True.
    """
    if paths:
        for p in paths:
            sync_path_to_host(p, host, user, verbose, cmd, gid, fatal)


def sync_to_peers(peer_interface, user, paths=None, verbose=False, cmd=None,
                  gid=None, fatal=False):
    """Sync all hosts to an specific path

    The type of group is integer, it allows user has permissions to
    operate a directory have a different group id with the user id.

    Propagates exception if any operation fails and fatal=True.
    """
    if paths:
        for host in collect_authed_hosts(peer_interface):
            sync_to_peer(host, user, paths, verbose, cmd, gid, fatal)
