#!/usr/bin/python

import argparse
import errno
import logging
import os
import os.path
import re
import subprocess
import sys
import tempfile


log_name = __name__
if log_name == '__main__':
    log_name = os.path.basename(sys.argv[0])
log = logging.getLogger(log_name)


class ActivateError(Exception):
    """
    OSD activation error
    """

    def __str__(self):
        doc = self.__doc__.strip()
        return ': '.join([doc] + [str(a) for a in self.args])


class BadMagicError(ActivateError):
    """
    Does not look like a Ceph OSD, or incompatible version
    """


class TruncatedLineError(ActivateError):
    """
    Line is truncated
    """


class TooManyLinesError(ActivateError):
    """
    Too many lines
    """


class FilesystemTypeError(ActivateError):
    """
    Cannot discover filesystem type
    """


class MountError(ActivateError):
    """
    Mounting filesystem failed
    """


def maybe_mkdir(*a, **kw):
    try:
        os.mkdir(*a, **kw)
    except OSError, e:
        if e.errno == errno.EEXIST:
            pass
        else:
            raise


def must_be_one_line(line):
    if line[-1:] != '\n':
        raise TruncatedLineError(line)
    line = line[:-1]
    if '\n' in line:
        raise TooManyLinesError(line)
    return line


def read_one_line(parent, name):
    """
    Read a file whose sole contents are a single line.

    Strips the newline.

    :return: Contents of the line, or None if file did not exist.
    """
    path = os.path.join(parent, name)
    try:
        line = file(path, 'rb').read()
    except IOError as e:
        if e.errno == errno.ENOENT:
            return None
        else:
            raise

    try:
        line = must_be_one_line(line)
    except (TruncatedLineError, TooManyLinesError) as e:
        raise ActivateError('File is corrupt: {path}: {msg}'.format(
                path=path,
                msg=e,
                ))
    return line


def write_one_line(parent, name, text):
    """
    Write a file whose sole contents are a single line.

    Adds a newline.
    """
    path = os.path.join(parent, name)
    tmp = '{path}.{pid}.tmp'.format(path=path, pid=os.getpid())
    with file(tmp, 'wb') as f:
        f.write(text + '\n')
        os.fsync(f.fileno())
    os.rename(tmp, path)


CEPH_OSD_ONDISK_MAGIC = 'ceph osd volume v026'


def check_osd_magic(path):
    """
    Check that this path has the Ceph OSD magic.

    :raises: BadMagicError if this does not look like a Ceph OSD data
    dir.
    """
    magic = read_one_line(path, 'magic')
    if magic is None:
        # probably not mkfs'ed yet
        raise BadMagicError(path)
    if magic != CEPH_OSD_ONDISK_MAGIC:
        raise BadMagicError(path)


def check_osd_id(osd_id):
    """
    Ensures osd id is numeric.
    """
    if not re.match(r'^[0-9]+$', osd_id):
        raise ActivateError('osd id is not numeric')


def get_osd_id(path):
    osd_id = read_one_line(path, 'whoami')
    if osd_id is not None:
        check_osd_id(osd_id)
    return osd_id


# TODO depend on python2.7
def _check_output(*args, **kwargs):
    process = subprocess.Popen(
        stdout=subprocess.PIPE,
        *args, **kwargs)
    out, _ = process.communicate()
    ret = process.wait()
    if ret:
        cmd = kwargs.get("args")
        if cmd is None:
            cmd = args[0]
        raise subprocess.CalledProcessError(ret, cmd, output=out)
    return out


def allocate_osd_id(
    cluster,
    fsid,
    keyring,
    ):
    log.debug('Allocating OSD id...')
    try:
        osd_id = _check_output(
            args=[
                'ceph',
                '--cluster', cluster,
                '--name', 'client.bootstrap-osd',
                '--keyring', keyring,
                'osd', 'create', '--concise',
                fsid,
                ],
            )
    except subprocess.CalledProcessError as e:
        raise ActivateError('ceph osd create failed', e)
    osd_id = must_be_one_line(osd_id)
    check_osd_id(osd_id)
    return osd_id


def mkfs(
    path,
    cluster,
    osd_id,
    fsid,
    keyring,
    ):
    monmap = os.path.join(path, 'activate.monmap')
    subprocess.check_call(
        args=[
            'ceph',
            '--cluster', cluster,
            '--name', 'client.bootstrap-osd',
            '--keyring', keyring,
            'mon', 'getmap', '-o', monmap,
            ],
        )

    subprocess.check_call(
        args=[
            'ceph-osd',
            '--cluster', cluster,
            '--mkfs',
            '--mkkey',
            '-i', osd_id,
            '--monmap', monmap,
            '--osd-data', path,
            '--osd-journal', os.path.join(path, 'journal'),
            '--osd-uuid', fsid,
            '--keyring', os.path.join(path, 'keyring'),
            ],
        )
    # TODO ceph-osd --mkfs removes the monmap file?
    # os.unlink(monmap)


def auth_key(
    path,
    cluster,
    osd_id,
    keyring,
    ):
    subprocess.check_call(
        args=[
            'ceph',
            '--cluster', cluster,
            '--name', 'client.bootstrap-osd',
            '--keyring', keyring,
            'auth', 'add', 'osd.{osd_id}'.format(osd_id=osd_id),
            '-i', os.path.join(path, 'keyring'),
            'osd', 'allow *',
            'mon', 'allow rwx',
            ],
        )


def move_mount(
    path,
    cluster,
    osd_id,
    ):
    log.debug('Moving mount to final location...')
    parent = '/var/lib/ceph/osd'
    osd_data = os.path.join(
        parent,
        '{cluster}-{osd_id}'.format(cluster=cluster, osd_id=osd_id),
        )
    maybe_mkdir(osd_data)
    subprocess.check_call(
        args=[
            'mount',
            '--move',
            '--',
            path,
            osd_data,
            ],
        )


def upstart_start(
    cluster,
    osd_id,
    ):
    log.debug('Starting service...')
    subprocess.check_call(
        args=[
            'initctl',
            # use emit, not start, because start would fail if the
            # instance was already running
            'emit',
            # since the daemon starting doesn't guarantee much about
            # the service being operational anyway, don't bother
            # waiting for it
            '--no-wait',
            '--',
            'ceph-osd',
            'cluster={cluster}'.format(cluster=cluster),
            'id={osd_id}'.format(osd_id=osd_id),
            ],
        )


def detect_fstype(
    dev,
    ):
    fstype = _check_output(
        args=[
            'blkid',
            # we don't want stale cached results
            '-p',
            '-s', 'TYPE',
            '-o' 'value',
            '--',
            dev,
            ],
        )
    fstype = must_be_one_line(fstype)
    return fstype


MOUNT_OPTIONS = dict(
    ext4='user_xattr',
    )


def mount(
    dev,
    ):
    # pick best-of-breed mount options based on fs type
    try:
        fstype = detect_fstype(dev)
    except (subprocess.CalledProcessError,
            TruncatedLineError,
            TooManyLinesError) as e:
        raise FilesystemTypeError(
            'device {dev}'.format(dev=dev),
            e,
            )
    options = MOUNT_OPTIONS.get(fstype, '')

    # mount
    path = tempfile.mkdtemp(
        prefix='mnt.',
        dir='/var/lib/ceph/tmp',
        )
    try:
        subprocess.check_call(
            args=[
                'mount',
                '-o', options,
                '--',
                dev,
                path,
                ],
            )
    except subprocess.CalledProcessError as e:
        try:
            os.rmdir(path)
        except (OSError, IOError):
            pass
        raise MountError(e)

    return path


def activate(
    path,
    activate_key_template,
    do_mount,
    ):
    if do_mount:
        path = mount(dev=path)

    # TODO unmount on errors?

    check_osd_magic(path)

    ceph_fsid = read_one_line(path, 'ceph_fsid')
    if ceph_fsid is None:
        raise ActivateError('No cluster uuid assigned.')
    log.debug('Cluster uuid is %s', ceph_fsid)

    # TODO use ceph_fsid to find the right cluster
    cluster = 'ceph'
    log.debug('Cluster name is %s', cluster)

    fsid = read_one_line(path, 'fsid')
    if fsid is None:
        raise ActivateError('No OSD uuid assigned.')
    log.debug('OSD uuid is %s', fsid)

    keyring = activate_key_template.format(cluster=cluster)

    osd_id = get_osd_id(path)
    if osd_id is None:
        osd_id = allocate_osd_id(
            cluster=cluster,
            fsid=fsid,
            keyring=keyring,
            )
        write_one_line(path, 'whoami', osd_id)
    log.debug('OSD id is %s', osd_id)

    if not os.path.exists(os.path.join(path, 'ready')):
        log.debug('Initializing OSD...')
        # re-running mkfs is safe, so just run until it completes
        mkfs(
            path=path,
            cluster=cluster,
            osd_id=osd_id,
            fsid=fsid,
            keyring=keyring,
            )

    if not os.path.exists(os.path.join(path, 'active')):
        log.debug('Authorizing OSD key...')
        auth_key(
            path=path,
            cluster=cluster,
            osd_id=osd_id,
            keyring=keyring,
            )
        write_one_line(path, 'active', 'ok')

    move_mount(
        path=path,
        cluster=cluster,
        osd_id=osd_id,
        )

    if do_mount:
        # if we created a temp dir to mount it, remove it
        os.rmdir(path)

    upstart_start(
        cluster=cluster,
        osd_id=osd_id,
        )


def parse_args():
    parser = argparse.ArgumentParser(
        description='Activate a Ceph OSD',
        )
    parser.add_argument(
        '-v', '--verbose',
        action='store_true', default=None,
        help='be more verbose',
        )
    parser.add_argument(
        '--mount',
        action='store_true', default=None,
        help='mount the device first',
        )
    parser.add_argument(
        '--activate-key',
        metavar='PATH',
        help='bootstrap-osd keyring path template (%(default)s)',
        dest='activate_key_template',
        )
    parser.add_argument(
        'path',
        metavar='PATH',
        help='path to OSD data directory, or block device if using --mount',
        )
    parser.set_defaults(
        activate_key_template='/var/lib/ceph/bootstrap-osd/{cluster}.keyring',
        # we want to hold on to this, for later
        prog=parser.prog,
        )
    args = parser.parse_args()
    return args


def main():
    args = parse_args()

    loglevel = logging.INFO
    if args.verbose:
        loglevel = logging.DEBUG

    logging.basicConfig(
        level=loglevel,
        )

    try:
        activate(
            path=args.path,
            activate_key_template=args.activate_key_template,
            do_mount=args.mount,
            )
    except ActivateError as e:
        print >>sys.stderr, '{prog}: {msg}'.format(
            prog=args.prog,
            msg=e,
            )
        sys.exit(1)

if __name__ == '__main__':
    main()
