"""
Swift Local Server

Server to run on each swift box.  Provides api to access rings,
work with physical devices, control swift services, and manage fstab.
"""

import tornado.ioloop
import tornado.web
import tornado.gen
import tornado.httpclient
import tornado.locks
from tornado.process import Subprocess
import argparse
import logging
import time
import copy
import json
import traceback
import shutil
import os, sys, os.path
import xmltodict
import tornadoredis
import hashlib
import argparse
from netifaces import interfaces, ifaddresses, AF_INET
from swift.common.ring.ring import Ring
from tornado_lib import CallbackHandler
from collections import defaultdict

@tornado.gen.coroutine
def call_subprocess(cmd):
    """
    Wrapper around subprocess call using Tornado's Subprocess class.
    """
    sub_process = tornado.process.Subprocess(
        cmd, stdout=Subprocess.STREAM, stderr=Subprocess.STREAM
    )

    result, error = yield [
        tornado.gen.Task(sub_process.stdout.read_until_close),
        tornado.gen.Task(sub_process.stderr.read_until_close)
    ]

    raise tornado.gen.Return((result, error))

class SwiftRing(Ring):
    """
    Wrapper around default swift Ring object that allows on the fly rewrite
    and dictionary conversion.
    """
    def __init__(self, ring_file):
        super(SwiftRing, self).__init__(ring_file)
        self.ring_file = ring_file

    def to_dict(self):
        return {
            'partitions': self.partition_count,
            'replicas': self.replica_count,
            'devices': [dev for dev in self.devs if dev],
        }

    def rewrite(self, gz_body):
        with open(self.ring_file, 'w') as f:
            f.write(gz_body)

        return self.has_changed()

last_cached = 0
last_uuids = 0
cached_result = {}
cached_uuids = {}
locked_devices = defaultdict(tornado.locks.Lock)
list_services=[]

STRIP_KEYS = ('clock', 'width', 'configuration', 'capabilities', 'resources')
GIGABYTE = 1000*1000*1000
SWIFT_DRIVE_PREP = '/home/jtv/swift_management/current/swift-drive-prep'
MOUNT_DIR = '/srv/node'
FSTAB_FMT = {
    'xfs': "UUID={0} %s/{0} xfs noatime,noauto,nodiratime,nobarrier,logbufs=8,logbsize=256k 0 0\n" % MOUNT_DIR,
    'ext4': "UUID={0} %s/{0} ext4 noatime,noauto,barrier=0,data=writeback,nobh 0 0\n" % MOUNT_DIR,
}

RING_PATHS = {
    'object': '/etc/swift/object.ring.gz',
    'account': '/etc/swift/account.ring.gz',
    'container': '/etc/swift/container.ring.gz',
}

RINGS = defaultdict(lambda: None)

SERVICE_NAMES = (
    'all',
    'container-updater',
    'account-auditor',
    'object-replicator',
    'proxy-server',
    'container-replicator',
    'object-auditor',
    'object-expirer',
    'container-auditor',
    'container-server',
    'account-server',
    'account-reaper',
    'container-sync',
    'account-replicator',
    'object-updater',
    'object-server',
)

def delete_keys(d, keys):
    """
    Helper function to strip a list of keys out of a dict
    """
    for key in keys:
        if key in d:
            del d[key]

def read_fstab(valid_uuids=None):
    """
    Reads /etc/fstab into a dict keyed by whether or not device is a UUID device.
    """
    fstab = {'other_stuff': [], 'swift_devices': {}}

    with open('/etc/fstab', 'r') as f:
        for line in f:
            if line.startswith('UUID'):
                uuid = line[5:41]
                if not valid_uuids or uuid in valid_uuids:
                    fstab['swift_devices'][line[5:41]] = line
            else:
                fstab['other_stuff'].append(line)

    return fstab

@tornado.gen.coroutine
def mounted_devices():
    """
    Returns a dict of devices to UUIDs of devices mounted in /srv/node/
    """
    result, _ = yield call_subprocess(['mount'])
    uuids = {}
    for line in result.split('\n'):
        line = line.split(' ')
        if len(line) < 3 or not line[2].startswith('/srv/node/'):
            continue
        mount_point = line[2].split('/')
        uuids[mount_point[3]] = line[0]

    raise tornado.gen.Return(uuids)

def write_fstab(fstab):
    """
    Given an fstab dict from read_fstab, rewrites the fstab
    """
    body = ''.join(fstab['other_stuff'])
    for device in fstab['swift_devices'].values():
        body += device

    shutil.copyfile('/etc/fstab', 'fstab.backup')
    with open('/etc/fstab', 'w') as f:
        f.write(body)
        return True

    return False

def fstab_add(uuid, filesystem):
    """
    Validates then adds a new UUID to /etc/fstab corresponding to a swift device
    """
    if len(uuid) != 36 or filesystem not in FSTAB_FMT:
        return False

    fstab = read_fstab()
    fstab['swift_devices'][uuid] = FSTAB_FMT[filesystem].format(uuid)
    return write_fstab(fstab)

def fstab_del(uuid):
    """
    Deletes a UUID from /etc/fstab
    """
    fstab = read_fstab()
    if uuid in fstab['swift_devices']:
        del fstab['swift_devices'][uuid]

    write_fstab(fstab)

@tornado.gen.coroutine
def fstab_rewrite():
    """
    Given the current swift UUIDs, completely rewrites /etc/fstab to reflect them.
    """
    yield cache_uuids(refresh=True)
    valid_uuids = [device['uuid'] for device in cached_uuids.values()]

    fstab = read_fstab(valid_uuids)
    for device in cached_uuids.values():
        uuid = device['uuid']
        fs = device['type']
        if uuid not in fstab['swift_devices'] and fs in FSTAB_FMT:
            fstab['swift_devices'][uuid] = FSTAB_FMT[fs].format(uuid)

    write_fstab(fstab)

    for uuid in valid_uuids:
        mount = 'UUID={0}'.format(uuid)
        yield call_subprocess(['mount', mount])

@tornado.gen.coroutine
def mount_uuid(uuid):
    """
    Given a UUID as input, mounts it
    """
    yield call_subprocess(['mount', 'UUID={0}'.format(uuid)])

def mount_point(uuid):
    """
    Prepends the Swift mount directory to a UUID
    """
    return MOUNT_DIR + '/' + uuid

def check_blank(uuid):
    """
    Given a UUID, returns False if it is not mounted or if it has data already on it,
    and True otherwise.
    """
    mount_dir = mount_point(uuid)
    mounted = os.path.exists(mount_dir)
    if not mounted:
        return False

    dir_listing = os.listdir(mount_dir)
    return len(dir_listing) == 0 or (len(dir_listing) == 1 and dir_listing[0] == 'lost+found')

def parse_uuid(device):
    """
    Takes a blkid row and extracts the name, uuid, and fs type, returning the latter two in a hash
    """
    attrs = device.split(' ')
    name = attrs[0][:-1]
    dev_type = None
    uuid = None

    for attr in attrs[1:]:
        if '=' not in attr:
            continue
        k, v = attr.split('=')
        v = v.strip('"')
        if k == 'TYPE':
            dev_type = v
        elif k == 'UUID':
            uuid = v

    result = {}
    if dev_type:
        result['type'] = dev_type
    if uuid:
        result['uuid'] = uuid

    return result, name

@tornado.gen.coroutine
def cache_uuids(refresh=False):
    """
    Calls blkid to determine the list of device UUIDs on the system.
    If blkid has been called in the past 30 seconds, return the cached value.
    """
    global cached_uuids, last_uuids

    if refresh or time.time() - last_uuids > 30:
        result = yield call_subprocess(['blkid', '-c', '/dev/null'])
        devices = result[0].split('\n')
        uuids = {}
        for device in devices:
            val, name = parse_uuid(device)
            if name:
                uuid = val['uuid']
                yield mount_uuid(uuid)
                val['blank'] = check_blank(uuid)
                uuids[name] = val

        cached_uuids = uuids
        last_uuids = time.time()

@tornado.gen.coroutine
def uuid_for_device(device_path):
    """
    Given a device path, returns the uuid by calling blkid
    """

    result = yield call_subprocess(['blkid', '-c', '/dev/null', device_path])
    cached_uuids[device_path], _ = parse_uuid(result[0])

    raise tornado.gen.Return(cached_uuids[device_path])

class UUIDHandler(CallbackHandler):
    """
    Request handler for /uuids.
    """

    @tornado.gen.coroutine
    def get(self):
        """
        returns a hash of device path to dict containing
        keys type, uuid, and blank.

        type: filesystem of device
        uuid: uuid of device
        blank: is the device blank?
        """

        yield cache_uuids()

        self.write_with_callback(json.dumps(cached_uuids))

def ring_for_type(ring_type):
    """
    Returns Ring object for input ring name
    """
    return RINGS[ring_type]

def path_for_type(ring_type):
    """
    Return path to gzipped ring file
    """
    return RING_PATHS.get(ring_type)

@tornado.gen.coroutine
def local_devices(ring, allow_remote=False):
    """
    Returns a dict of the subset of devices in the ring that are on
    the local host
    """
    ring_dict = ring.to_dict()
    local_devices = []
    mounted_uuids = yield mounted_devices()
    valid_ips = []
    for ifaceName in interfaces():
        valid_ips += [i['addr'] for i in ifaddresses(ifaceName).get(AF_INET, [])]

    for device in ring_dict['devices']:
        device['mount_point'] = mounted_uuids.get(device['device'])
        if device['ip'] in valid_ips or allow_remote:
            local_devices.append(device)

    ring_dict['devices'] = local_devices
    raise tornado.gen.Return(ring_dict)

class SwiftInitHandler(CallbackHandler):
    """
    Request Handler for swift service management, at /swift_init
    """
    @tornado.gen.coroutine
    def get(self, svc='all'):
        """
        GET /swift_init/{svc_name}

        Get status for service name specified.  If no service name, all status is returned.
        """
        if svc not in SERVICE_NAMES:
            self.write_with_callback({'error': 'No such service {0}'.format(svc)})
            return

        result, _ = yield call_subprocess(['swift-init', svc, 'status'])
        status = {}
        for service in result.split('\n'):
            components = service.split(' ')
            if len(components) < 2:
                continue
            if components[0] == 'No':
                status[components[1]] = 'stopped'
            else:
                status[components[0]] = 'running'

        self.write_with_callback(status)

    @tornado.gen.coroutine
    def post(self, svc, action):
        """
        POST /swift_init/{svc_name}/{action}

        svc_name: service to manage
        action: either start or stop
        """
        if svc not in SERVICE_NAMES:
            self.write_with_callback({'error': 'No such service {0}'.format(svc)})
            return
        if action not in ('start', 'stop', 'restart'):
            self.write_with_callback({'error': 'action must be start, stop, or restart'})
            return

        yield call_subprocess(['swift-init', svc, action])
        self.write_with_callback({'success': True})

class HealthHandler(CallbackHandler):
    """
    Request Handler for /health, a health dashboard for swift devices.
    """

    @tornado.gen.coroutine
    def get(self, ring_type=None):
        """
        GET /health/{ring_type}

        Get the health report for the requested ring_type, or all rings if no ring type is given.
        Reports on available and total disk space and any unhealthy devices or hosts.
        """
        global cached_uuids

        if ring_type is None:
            if list_services:
                for service in list_services:
                    service_status, _ = yield call_subprocess(['swift-init', service, 'status'])
                    if 'No {0} running'.format(service) == service_status.strip():
                        self.set_status(503)
                        self.write_with_callback({'error':service_status.strip()})
                        return
            self.write_with_callback('ok')
            return

        ring = ring_for_type(ring_type)

        if not ring:
            self.write_with_callback({'error': 'Invalid ring type: {0}'.format(ring_type)})
            return

        ring_dict = yield local_devices(ring)
        if not cached_uuids:
            yield cache_uuids()

        uuid_map = {}
        for device, uuid in cached_uuids.iteritems():
            if uuid.get('uuid'):
                uuid_map[uuid['uuid']] = device

        used = 0
        total = 0

        for device in ring_dict['devices']:
            if device['mount_point']:
                uuid = device['device']
                path = '/srv/node/{0}'.format(uuid)
                st = os.statvfs(path)
                used += (st.f_blocks - st.f_bfree) * st.f_frsize
                total += st.f_blocks * st.f_frsize

        missing_devices = [device['device'] for device in ring_dict['devices'] if device['mount_point'] is None]
        missing_uuids = []
        for device in missing_devices:
            missing_uuids.append({'uuid': device, 'drive': uuid_map.get(device, 'unknown')})
        self.write_with_callback({
            'unhealthy_devices': missing_uuids,
            'used_space_TB': used / 1000000000000.0,
            'total_space_TB': total / 1000000000000.0,
            'percent_used': float(used) / total if total else 0,
        })

class SwiftRingHandler(CallbackHandler):
    """
    Request Handler for /swift, the endpoint to list and modify devices in each ring.
    Valid rings: account, container, object
    """

    @tornado.gen.coroutine
    def get(self, ring_type):
        """
        GET /swift/{ring_type}[?show_all=true]

        By default, shows the local devices in the ring named ring_type.  If the show_all
        url parameter is supplied, shows remote devices as well.
        """
        ring = ring_for_type(ring_type)
        show_all = self.get_argument('show_all', False)

        if not ring:
            self.set_status(404)
            self.write_with_callback({'error': 'Invalid ring type: {0}'.format(ring_type)})
            return

        ring_dict = yield local_devices(ring, show_all)
        self.write_with_callback(ring_dict)

    def post(self, ring_type):
        """
        POST /swift/{ring_type}

        Replace the gzipped ring file for ring_type with the POST body.
        """
        ring = ring_for_type(ring_type)

        if not self.request.body:
            self.write_with_callback({'error': 'need body to store'})
            return

        if not ring:
            ring_path = path_for_type(ring_type)
            if not ring_path:
                self.write_with_callback({'error': 'Invalid ring type: {0}'.format(ring_type)})
                return

            with open(ring_path, 'w') as f:
                f.write(self.request.body)

            if os.path.isfile(ring_path):
                RINGS[ring_type] = SwiftRing(ring_path)
            return

        changed = ring.rewrite(self.request.body)
        self.write_with_callback({'updated': changed})

class FSTabHandler(CallbackHandler):
    """
    Request Handler for /refresh_fstab, whose sole purpose is to rewrite the fstab
    """

    @tornado.gen.coroutine
    def post(self):
        """
        POST /refresh_fstab

        Overwrites the existing fstab with UUIDs found in the various ring files.
        """
        yield fstab_rewrite()
        self.write_with_callback({'success': True})

@tornado.gen.coroutine
def unmount_device(partition_path):
    """
    Removes the old entry of a device path from fstab,
    unmounts it and deletes the old mount point.
    """
    if os.path.exists(partition_path):
        print 'getting old uuid'
        old_uuid = yield uuid_for_device(partition_path)
        old_uuid = old_uuid.get('uuid')
        if old_uuid:
            print 'got old uuid {0}'.format(old_uuid)

            fstab_del(old_uuid)

            print 'unmounting...'
            for attempt in xrange(5):
                _, ret = yield call_subprocess(['umount', partition_path])
                if not ret or 'not mounted' in ret:
                    break

                print 'failed to unmount, retrying...'
                time.sleep(5)

            old_mount_point = mount_point(old_uuid)
            if os.path.exists(old_mount_point):
                os.rmdir(old_mount_point)

class DriveFormatHandler(CallbackHandler):
    """
    Request handler for /format_device.  Formats physical devices.
    """
    @tornado.gen.coroutine
    def post(self, device):
        """
        POST /format_device/{device}[?fs=ext4]

        device: e.g. sdab.  Device path is formed as /dev/ + device + 1.  This device path will be formatted.
        fs: either ext4 or xfs, default is xfs if not specified.

        Device is mounted post-formatting.
        """

        filesystem = self.get_argument('fs', 'xfs')
        if filesystem not in FSTAB_FMT:
            self.write_with_callback({'error': 'Invalid fs {0}, must be xfs or ext4'.format(filesystem)})

        with (yield locked_devices[device].acquire()):
            try:
                device_path = '/dev/' + device
                yield tornado.gen.Task(redis.sadd, 'formatting', device_path)
                partition = device_path + '1'

                yield unmount_device(partition)

                if filesystem == 'ext4':
                    yield call_subprocess(['mkfs.ext4', '-E', 'lazy_itable_init=0,lazy_journal_init=0', partition])
                else: # filesystem == 'xfs'
                    yield call_subprocess(['mkfs.xfs', '-f', '-i', 'size=1024', partition])

                print 'getting new uuid'
                uuid = yield uuid_for_device(partition)
                if 'uuid' not in uuid:
                    self.set_status(500)
                    self.write_with_callback({'error': 'Could not find uuid'})
                uuid = uuid['uuid']

                print 'got uuid {0}'.format(uuid)
                new_mount_point = mount_point(uuid)

                yield call_subprocess(['mkdir', '-p', new_mount_point])

                print 'editing fstab...'
                fstab_add(uuid, filesystem)

                print 'mounting drive'
                yield mount_uuid(uuid)

                print 'chown'
                yield call_subprocess(['chown', 'swift:swift', new_mount_point])

                self.write_with_callback({'success': True})
            except Exception:
                print traceback.format_exc()
                self.set_status(500)
                self.write_with_callback({'success': False})
            finally:
                yield tornado.gen.Task(redis.srem, 'formatting', device_path)

class DrivePrepHandler(CallbackHandler):
    """
    Request Handler for /prep_device. Runs swift-drive-prep on devices.
    """

    @tornado.gen.coroutine
    def post(self, device):
        """
        POST /prep_device/{device}

        Call swift-drive-prep on /dev/ + device
        """
        print 'prepping device'
        device_path = '/dev/' + device
        partition_path = device_path + '1'

        with (yield locked_devices[device].acquire()):
            yield tornado.gen.Task(redis.sadd, 'prepping', device_path)
            yield unmount_device(partition_path)

            try:
                if not os.path.exists(device_path):
                    self.write_with_callback({'error': 'No such device {0}'.format(device_path)})
                    return


                print 'calling swift_drive_prep'
                _, err = yield call_subprocess([SWIFT_DRIVE_PREP, device_path])
                if err:
                    print err
                    self.write_with_callback({'success': False})
                    return

                self.write_with_callback({'success': True})
            except Exception:
                print traceback.format_exc()
                self.set_status(500)
                self.write_with_callback({'success': False})
            finally:
                yield tornado.gen.Task(redis.srem, 'prepping', device_path)

class LSHWHandler(CallbackHandler):
    """
    Request Handler to interact with lshw command.  Get information about storage hardware on the box.
    """
    @tornado.gen.coroutine
    def get(self):
        """
        GET /disks

        Returns storage hardware hierarchy obtained from lshw with superflous data stripped out.
        """
        global cached_result, last_cached

        if time.time() - last_cached > 9000:
            result = yield call_subprocess(['lshw', '-c', 'storage', '-c', 'disk', '-xml'])
            body = '\n'.join(result[0].split('\n')[5:])
            body = '<devices>\n' + body + '</devices>\n'
            nodes = xmltodict.parse(body)

            if 'node' in nodes['devices']:
                top_level = nodes['devices']['node']
                if type(top_level) != list:
                    top_level = [top_level]

                for node in top_level:
                    delete_keys(node, STRIP_KEYS)
                    if type(node) is not unicode and 'node' in node:
                        delete_keys(node['node'], STRIP_KEYS)
                        if 'size' in node['node']:
                            node['node']['size'] = str(int(node['node']['size']['#text'])/GIGABYTE) + 'GB'

                        for inner_node in node['node']:
                            if type(inner_node) is not unicode:
                                if 'size' in inner_node:
                                    inner_node['size'] = str(int(inner_node['size']['#text'])/GIGABYTE) + 'GB'
                                delete_keys(inner_node, STRIP_KEYS)

            cached_result = nodes
            last_cached = time.time()

        self.write_with_callback(json.dumps(cached_result))

class RedisHandler(CallbackHandler):
    """
    Request Handler to introspect into redis and get a list of currently formatting devices.
    """
    @tornado.gen.coroutine
    def get(self):
        """
        GET /redis

        Returns lists of devices being prepped and formatted.
        """
        prepping = yield tornado.gen.Task(redis.smembers, 'prepping')
        formatting = yield tornado.gen.Task(redis.smembers, 'formatting')
        self.write({'prepping': list(prepping), 'formatting': list(formatting)})

class ZoneHandler(CallbackHandler):
    """
    Request Handler for /zone, gets local rack number.
    """
    @tornado.gen.coroutine
    def get(self):
        """
        GET /zone

        Returns {'zone': zone} where zone is some integer between 0 and 4096.
        Calculated from a hash of the rack name.
        """

        ret, _ = yield call_subprocess(['facter', '-p', 'rack_name'])
        hex_digest = hashlib.md5(ret).hexdigest()
        self.write({'zone': int(hex_digest, 16) % 4096})

if __name__ == "__main__":
    redis = tornadoredis.Client()
    redis.connect()
    parser = argparse.ArgumentParser()
    parser.add_argument('services',  nargs='*', help='Services to check health')
    args = parser.parse_args()

    list_services = args.services

    for ring, path in RING_PATHS.iteritems():
        try:
            RINGS[ring] = SwiftRing(path)
        except Exception:
            print traceback.format_exc()

    application = tornado.web.Application([
        (r"/disks", LSHWHandler),
        (r"/health", HealthHandler),
        (r"/health/(.*)", HealthHandler),
        (r"/uuids", UUIDHandler),
        (r"/zone", ZoneHandler),
        (r"/refresh_fstab", FSTabHandler),
        (r"/swift/(.*)", SwiftRingHandler),
        (r"/prep_device/(.*)", DrivePrepHandler),
        (r"/format_device/(.*)", DriveFormatHandler),
        (r"/swift_init/(.*)/(start|stop)", SwiftInitHandler),
        (r"/swift_init/(.*)", SwiftInitHandler),
        (r"/redis", RedisHandler),
    ])
    application.listen(8082)
    tornado.ioloop.IOLoop.instance().start()
