#! /usr/bin/python3
#
#  Copyright © 2015 Marc-André Pelletier <mpelletier@wikimedia.org>
#  Copyright © 2015 Yuvi Panda <yuvipanda@gmail.com>
#
#  Permission to use, copy, modify, and/or distribute this software for any
#  purpose with or without fee is hereby granted, provided that the above
#  copyright notice and this permission notice appear in all copies.
#
#  THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
#  WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
#  MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
#  ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
#  WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
#  ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
#  OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
#
#
#  THIS FILE IS MANAGED BY PUPPET
#
#  Source: modules/labstore/nfs-project-exports-daemon
#  From:   labstore::fileserver
#

from urllib.request import urlopen
import ipaddress
import argparse
import yaml
import json
import os
import time
import logging
import sys
import subprocess


# Volumes that need an entry in exports.d
# /data/scratch and /public/dumps are available to anyone
# who wants, and hence do not need a entry in exports.d.
VOLUMES_NEEDING_EXPORTS = ['project', 'home']


def is_valid_ipv4(ip):
    """
    Returns true if ip is a valid ipv4 address
    """
    try:
        ipaddress.IPv4Address(ip)
        return True
    except ipaddress.AddressValueError:
        return False


class Project:
    EXPORTS_TEMPLATE = '/exp/project/{name} ' + \
        '-rw,nohide,fsid=00000000000000000-{gid}-0000000000' + \
        ',subtree_check,async,no_root_squash ' + \
        '{instance_ips}'

    def __init__(self, name, gid, instance_ips, volumes):
        self.name = name
        self.instance_ips = instance_ips
        self.volumes = volumes
        self.gid = gid

    def get_exports(self):
        return Project.EXPORTS_TEMPLATE.format(
            name=self.name,
            gid=self.gid,
            instance_ips=' '.join(self.instance_ips)
        )


def get_instance_ips(project):
    """
    Return a list of Instance internal IPs for a given project

    This uses the Wikitech API to fetch this data
    """
    url = ("https://wikitech.wikimedia.org/w/api.php" +
           "?action=query&list=novainstances&niproject=%s" +
           "&niregion=eqiad&format=json") % project
    try:
        data = json.loads(urlopen(url, timeout=15).read().decode('utf-8'))
    except:
        logging.exception('Error fetching instance ip list for project %s', project)
        sys.exit(1)
    ips = []
    if 'query' in data and 'novainstances' in data['query']:
        for instance in data['query']['novainstances']:
            # Only provide internal IPs!
            ips += [ip for ip in instance['ip']
                    if is_valid_ipv4(ip) and ip.startswith('10.')]
    return ips


def get_public_exports(mounts_config):
    return mounts_config['public']


def get_projects_with_nfs(mounts_config):
    """
    Returns list of populated Project objects that need NFS exports
    """
    projects = []
    for name, config in mounts_config['private'].items():
        logging.debug('Fetching config for project %s', name)
        if 'mounts' in config:
            mounts = [k for k, v in config['mounts'].items()
                      if k in VOLUMES_NEEDING_EXPORTS and v]
            if len(mounts) == 0:
                # Skip project if it has no private mounts
                logging.info('Skipping exports for %s, no private mounts', name)
                continue
        else:
            continue
        project = Project(name, config['gid'], get_instance_ips(name), mounts)
        projects.append(project)
        logging.info('Fetched config for project %s, with %s instances',
                     name, len(project.instance_ips))

    # Validate that there are no duplicate gids
    gids = [p.gid for p in projects]
    if len(set(gids)) != len(gids):
        # OMG DUPLICATES
        logging.error('Duplicate GIDs found in project config, aborting')
        sys.exit(1)

    logging.info("Found %s projects requiring private mounts", len(projects))
    return projects


def sync_exports_files(mounts_config, exports_d_base):
    """
    Generate exports files for syncfs
    """
    existing_exports = [
        os.path.join(exports_d_base, filename)
        for filename in os.listdir(exports_d_base)]

    public_exports = get_public_exports(mounts_config)
    for name, content in public_exports.items():
        logging.debug('Writing exports file for public export %s', name)
        path = os.path.join(exports_d_base, 'public_%s.exports' % name)
        with open(path, 'w') as f:
            f.write(content)
        logging.info('Wrote exports file for public export %s', name)
        if path in existing_exports:
            existing_exports.remove(path)

    projects = get_projects_with_nfs(mounts_config)
    for project in projects:
        logging.debug('Writing exports file for %s', project.name)
        path = os.path.join(exports_d_base, '%s.exports' % project.name)
        with open(path, 'w') as f:
            f.write(project.get_exports())
        logging.info('Wrote exports file for %s', project.name)
        if path in existing_exports:
            existing_exports.remove(path)

    if len(existing_exports) != 0:
        logging.info('Extra exports.d files found, purging')
        for existing_export in existing_exports:
            with open(existing_export) as f:
                logging.info('Deleting %s with contents %s', existing_export, f.read())
            os.remove(existing_export)
            logging.info('Deleted %s', existing_export)


def exportfs():
    logging.debug('Attempting to exportfs')
    try:
        subprocess.check_call([
            '/usr/bin/sudo',
            '/usr/sbin/exportfs',
            '-ra'
        ])
    except:
        logging.exception('Failed running exportfs -ra')
    logging.info('Successfully ran exportfs')


if __name__ == "__main__":
    argparser = argparse.ArgumentParser()
    argparser.add_argument(
        '--exports-d-path',
        help='Dir to write exports files to',
    )
    argparser.add_argument(
        '--config-path',
        help='Path to YAML file containing config of which exports to maintain',
    )
    argparser.add_argument(
        '--debug',
        help='Turn on debug logging',
        action='store_true'
    )
    argparser.add_argument(
        '--dry-run',
        help='Do a dry run, do not call exportfs',
        action='store_true'
    )

    args = argparser.parse_args()

    logging.basicConfig(
        format='%(asctime)s %(levelname)s %(message)s',
        level=logging.DEBUG if args.debug else logging.INFO)

    if os.getuid() == 0:
        logging.error('Daemon started as root, exiting')
        sys.stderr.write("This daemon should should not be run as root!\n")
        sys.exit(1)

    logging.info('Daemon starting')

    while True:
        try:
            with open(args.config_path) as f:
                config = yaml.safe_load(f)
        except:
            logging.exception('Could not load projects config file from %s', args.config_path)
            sys.exit(1)
        sync_exports_files(config, args.exports_d_path)
        if not args.dry_run:
            exportfs()
        time.sleep(60)
