#!/usr/bin/env python
#
# create-nodes.py: create undercloud nodes for Openstack
#
# Copyright (C) 2014 Ravello Systems, Inc.
#
# 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.

"""Create a virtual bare metal infrastructure.

Usage:
  ravello-create-nodes [options] [-c <cpus>] [-m <memory>] [-r <disk>]
                       [--nic-model <model>] [--disk-model <model>]
                       [--svm] [--network <netspec>]...
                       [-s <service>]... [--inbound-access <access>]
                       [-k <keypair>] [--default-user <user>]
                       [--cloud <cloud>] [--region <region>]
                       [--optimization <optimization>]
                       [--no-autostart] [--autostop <interval>]
                       [-a <application> | -n <name>] [--cdrom]
                       [-f <flavor>] [-i <image>] <count>
  ravello-create-nodes -h | --help

The ravello-create-nodes (r-c-n) command creates a new "virtual bare metal"
infrastructure. A virtual bare metal infrastructure is set of virtual machines
that behave as if they were physical servers. They can have arbitrary hardware
sizes, private layer-2 networking and optionally have AMD-SVM (c) features
enabled so that they can run hypervisors.

Image configuration:

Some options need to perform an OS level configuration. These options include
"--network", "--default-user" and "--keypair". For this to work it is required
that the OS flavor is specified using "--flavor" and that CloudInit is
installed in the image. Use "--flavor=?" to see a list of available flavors.

Specifying multiple values:

By default an option specifies a value that is the same for all nodes. Certain
options however support specifying a value that is different for different
nodes. In this case separate the values with a comma. The last value is
repeated. For example, "--disk 100G" specifies that all nodes should have a
100GB disk, but "--disk 100G,50G" specifies that the first node should have
100GB but all other nodes 50GB (the 50G is repeated).

Mandatory arguments:

  <count>           The number of nodes to create.

{common_options}

Arguments:
  -c <cpus>, --cpus <cpus>
                    The number of CPUs to use. Accepts multiple values.
                    [default: 1]
  -m <memory>, --memory <memory>
                    The memory size. Specify as an integer with an optional 'M'
                    or 'G' suffix for Megabytes and Gigabytes respectively. If
                    no suffix is provided, 'M' is assumed. Accepts multiple
                    values. [default: 2048]
  -r <disk>, --disk <disk>
                    The disk size. Specify as an integer with an optional 'M'
                    or 'G' suffix for Megabytes and Gigabytes respectively. If
                    no suffix is provided, 'M' is assumed. Accepts multiple
                    values. [default: 50G]
  --nic-model <model>
                    The NIC type to add. Must be 'e1000' or 'virtio'.
                    [default: virtio]
  --disk-model <model>
                    The disk type to add. Must be 'ide' or 'virtio'.
                    [default: virtio]
  --svm             Enable the AMD-SVM (C) instruction set. You will be able to
                    run a hypervisor in the guest.
  --network <netspec>
                    Add a network. The network specification must either be
                    the string 'dhcp', or in network/bits format. Requires
                    CloudInit.
  -s <service>, --service <service>
                    Add a network service. Must either be a port number or a
                    service name from /etc/services.
  --inbound-access <access>
                    Inbound access method. Must be either 'publicip', or
                    'portforwarding'. This option accepts multiple values.
                    [default: portforwarding]
  -k <keypair>, --keypair <keypair>
                    Specify a keypair to inject into the VM. Requires
                    CloudInit.
  --default-user <user>
                    Set the default login user. Requires CloudInit.
                    [default: ravello]
  --cloud <cloud>   Deploy into this cloud.
  --region <region>
                    Deploy into this region.
  --optimization <optimization>
                    Select this optimization level. Can be 'cost' or
                    'performance'.
  --no-autostart    Do not automatically start the application.
  --autostop <interval>
                    Set an autostop timer. Specify as an integer with an
                    optional 'M' suffix for minutes or 'H' for hours. The
                    default suffix is 'M'. A value of 0 means that the autostop
                    is disabled. [default: 0]
  -a <application>, --application <application>
                    Instead of creating a new application, add the nodes to an
                    existing application. Specify as an ID or a name.
  -n <name>, --name <name>
                    The name of the application to use. If not specified then a
                    new unique application name is created.
  --cdrom           The disk image is a bootable CD-ROM. An empty disk image
                    will be added as well of the size specifed by --disk.
  -f <flavor>, --flavor <flavor>
                    The OS family of the image. Must be 'redhat' or 'debian'.
  -i <image>, --image <image>
                    The disk image to use for the nodes. The image must have
                    been previously uploaded in Ravello. Specify the ID or the
                    image name. If this argument is not specified an empty disk
                    will be created.
  -h, --help        Show this help text.
"""

from __future__ import absolute_import, print_function

import os
import re
import sys
import json

from docopt import docopt
from collections import namedtuple

from ravello_sdk import RavelloClient, RavelloError
from ravello_cli import setup_logger, create_client
from ravello_cli import common_options, parse_common_arguments
from ravello_cli import (validate_int_arg, validate_size_arg,
        validate_enum_arg, validate_network_arg, validate_service_arg,
        validate_interval_arg, expand_multival_arg)
from ravello_cli import get_diskimage, get_keypair, get_application
from ravello_cli import new_name
from ravello_cli import inet_aton, inet_ntoa, mac_aton, mac_ntoa

__doc__ = __doc__.format(common_options=common_options)

prog = os.path.basename(sys.argv[0])

nic_models = ('e1000', 'virtio')
disk_models = ('ide', 'virtio')
optimizations = ('cost', 'performance')
flavors = []
inbound_access = ('publicip', 'portforwarding')

magic_svm_cpuids = [
    { "index": "0", "value": "0000000768747541444d416369746e65" },
    { "index": "1", "value": "000006fb00000800c0802000078bfbfd" },
    { "index": "8000000a", "value": "00000001000000400000000000000088" },
    { "index": "80000000", "value": "8000000a000000000000000000000000" },
    { "index": "80000001", "value": "00000000000000000000001520100800" }, ]

mac_base = '2c:c2:60:00:00:00'


def parse_arguments(args):
    """Check the command line arguments (values only, no references)."""
    values = parse_common_arguments(args)
    values['count'] = count = validate_int_arg(args, '<count>', 1, 100)
    values['cpus'] = [validate_int_arg(value, '--cpus', 1, 4)
                      for value in expand_multival_arg(args, '--cpus', count)]
    values['memory'] = [validate_size_arg(value, '--memory', 'M', 1024)
                        for value in expand_multival_arg(args, '--memory', count)]
    values['disk'] = [validate_size_arg(value, '--disk', 'M', 2000)
                      for value in expand_multival_arg(args, '--disk', count)]
    values['nic_model'] = validate_enum_arg(args, '--nic-model', nic_models)
    values['disk_model'] = validate_enum_arg(args, '--disk-model', disk_models)
    values['svm'] = bool(args.get('--svm'))
    values['network'] = [validate_network_arg(value, '--network')
                         for value in args['--network'] or ['dhcp']]
    values['service'] = [validate_service_arg(value, '--service')
                         for value in ['ssh'] + args['--service']]
    values['inbound_access'] = [validate_enum_arg(value, '--inbound-access', inbound_access)
                                for value in expand_multival_arg(args, '--inbound-access', count)]
    values['keypair'] = args['--keypair']
    values['default_user'] = args['--default-user']
    values['cloud'] = args['--cloud']
    values['region'] = args['--region']
    optim = validate_enum_arg(args, '--optimization', optimizations)
    if optim is None:
        optim = 'performance' if values['cloud'] else 'cost'
    elif values['cloud'] and optim != 'performance':
        raise ValueError('specifying --cloud requires performance optimization')
    values['optimization'] = optim
    values['autostart'] = not bool(args['--no-autostart'])
    values['autostop'] = validate_interval_arg(args, '--autostop', 'H')
    values['application'] = args['--application']
    values['name'] = args['--name']
    values['cdrom'] = bool(args.get('--cdrom'))
    values['flavor'] = validate_enum_arg(args, '--flavor', [f.name for f in flavors])
    values['image'] = args['--image']
    return values


def show_help(args):
    """Check for various --foo=? arguments and show help if needed."""
    # Note that --cloud=? is handled later because it depends on the
    # application itself.
    if args['flavor'] == '?':
        print('Available flavors:')
        for flavor in flavors:
            print(" * '{0}' - {1}".format(flavor.name, flavor.description))
        return True
    if args['nic_model'] == '?':
        print('Available NIC models:')
        for model in nic_models:
            print(" * '{0}'".format(model))
        return True
    if args['disk_model'] == '?':
        print('Available disk models:')
        for model in disk_models:
            print(" * '{0}'".format(model))
        return True
    return False


re_flavor = re.compile('\[flavor: (\w+)\]')

def resolve_arguments(client, args):
    """Check references in command-line arguments to objects in Ravello."""
    values = args.copy()
    if args['image']:
        image = get_diskimage(client, args['image'])
        if image is None:
            raise ValueError('no such image: {0}'.format(args['image']))
        values['image'] = image
    if args['keypair']:
        keypair = get_keypair(client, args['keypair'])
        if keypair is None:
            raise ValueError('no such keypair: {0}'.format(args['keypair']))
        values['keypair'] = keypair
    if args['application']:
        application = get_application(client, args['application'])
        if application is None:
            raise ValueError('no such application: {0}'.format(args['application']))
        values['application'] = application
    if args['flavor']:
        values['flavor'] = get_flavor(args['flavor'])
    elif args['image']:
        mobj = re_flavor.search(image.get('description', ''))
        if mobj:
            flavor = get_flavor(mobj.group(1))
            if flavor:
                values['flavor'] = flavor
    if values['flavor']:
        print("Using flavor '{0}'.".format(values['flavor'].name))
    return values


def final_args_check(args):
    """Final arguments check."""
    flavor = args['flavor']
    if args['network'] != 'dhcp' and flavor is None:
        raise ValueError('specifying --network requires specifying a flavor.\n'
                         'Use "{0} --flavor=?" for a list'.format(prog))
    if args['keypair'] and flavor is None:
        raise ValueError('specifying --keypair requires specifying a flavor.\n'
                         'Use "{0} --flavor=?" for a list'.format(prog))
    if args['default_user'] != 'ravello' and flavor is None:
        raise ValueError('specifying --default-user requires specifying a flavor.\n'
                         'Use "{0} --flavor=?" for a list'.format(prog))


# CloudInit stuff

def get_cc_user(username):
    """Return a cloud-config section for changing the default user."""
    return {'disable_root': False,
            'ssh_pwauth': False,
            'system_info': {
                'default_user': {
                    'name': username,
                    'passwd': 'nNfoV1qIPixN2',  # 'ravelloCloud' - console only
                    'lock-passwd': False,
                    'sudo': 'ALL=(ALL) NOPASSWD:ALL'}}}


def get_cc_network_redhat(vm):
    """Return a cloud-config section for creating the static networking
    definitions for *vm*."""
    cloudcfg = {}
    files = cloudcfg['write_files'] = []
    for conn in vm['networkConnections']:
        ifcfg = {'path': '/etc/sysconfig/network-scripts/ifcfg-{0}'.format(conn['name']),
                 'permissions': 493 }  # 0755
        ipcfg = conn['ipConfig']
        stcfg = ipcfg.get('staticIpConfig')
        bootproto = 'static' if stcfg else 'dhcp'
        content = {'NAME': conn['name'],
                   'BOOTPROTO': bootproto,
                   'HWADDR': conn['device']['mac'],
                   'ONBOOT': 'yes'}
        if bootproto == 'static':
            content['IPADDR'] = stcfg['ip']
            content['NETMASK'] = stcfg['mask']
            if 'gateway' in stcfg:
                content['GATEWAY'] = stcfg['gateway']
            if 'dns' in stcfg:
                content['DNS1'] = stcfg['dns']
        content = ''.join(['{0}={1}\n'.format(*item) for item in content.items()])
        ifcfg['content'] = content
        files.append(ifcfg)
    cloudcfg['runcmd'] = ['shutdown -r +1']
    return cloudcfg


def get_cc_network_debian(vm):
    """Return a cloud-config section for creating the static networking
    definitions for *vm*."""
    cloudcfg = {}
    files = cloudcfg['write_files'] = []
    etcnetif = {'path': '/etc/network/interfaces',
                'permissions': 493 }  # 0755
    lines = []
    lines.append('auto {0}\n'.format(' '.join([conn['name']
                        for conn in vm['networkConnections']])))
    for conn in vm['networkConnections']:
        ipcfg = conn['ipConfig']
        stcfg = ipcfg.get('staticIpConfig')
        bootproto = 'static' if stcfg else 'dhcp'
        lines.append('iface {0} inet {1}\n'.format(conn['name'], bootproto))
        lines.append('  address {0}\n'.format(stcfg['ip']))
        lines.append('  netmask {0}\n'.format(stcfg['mask']))
        if 'gateway' in stcfg:
            lines.append('  gateway {0}\n'.format(stcfg['gateway']))
        if 'dns' in stcfg:
            lines.append('  dns-nameservers {0}\n'.format(stcfg['dns']))
            lines.append('  dns-domain localdomain\n')
    etcnetif['content'] = ''.join(lines)
    files.append(etcnetif)
    cloudcfg['runcmd'] = ['shutdown -r +1']
    return cloudcfg


def merge_cc(a, b):
    """Merge cloud-config *b* into *a*."""
    for key in b:
        if key not in a:
            a[key] = b[key]
        elif isinstance(a[key], list):
            if not isinstance(b[key], list):
                raise ValueError('cannot merge list with non-list')
            a[key] += b[key]
        elif isinstance(a[key], dict):
            if not isinstance(b[key], list):
                raise ValueError('cannot merge dict with non-dict')
            merge_cc(a[key], b[key])
        else:
            a[key] = b[key]


# VM flavors

Flavor = namedtuple('Flavor', ('name', 'description', 'cloudinit', 'get_cc_network'))

flavor = Flavor('redhat', 'Red Hat family (RHEL, Fedora, CentOS, SL, ...)',
                True, get_cc_network_redhat)
flavors.append(flavor)
flavor = Flavor('debian', 'Debian family (Debian, Ubuntu, Mint, ...)',
                True, get_cc_network_debian)
flavors.append(flavor)


def get_flavor(name):
    """Return a flavor by name."""
    for flavor in flavors:
        if flavor.name == name:
            return flavor


# Main functions

def create_application(client, args):
    """Create a new application or get an existing one.

    The application is returned as a dictionary.
    """
    if args['application']:
        app = args['application']
    elif args['name']:
        app = {'name': args['name'], 'description': 'Created by r-c-n'}
        app = client.create_application(app)
    else:
        apps = client.get_applications()
        existing = set((app['name'] for app in apps))
        name = new_name('Cloud', existing)
        app = {'name': name, 'description': 'Created by r-c-n'}
        app = client.create_application(app)
    print("Using application '{0}'.".format(app['name']))
    return app


def create_vm(name, args, n):
    """Create a VM definition."""
    # General settings
    vm = {'name': name,
          'description': 'Created by r-c-n',
          'os': 'linux_manuel',  # sic.
          'baseVmId': 0}
    vm['numCpus'] = args['cpus'][n]
    vm['memorySize'] = {'value': args['memory'][n]//(2**20), 'unit': 'MB'}
    if args['svm']:
        vm['cpuIds'] = magic_svm_cpuids
    if args['keypair']:
        vm['keypairId'] = args['keypair']['id']
    # Disk + optional cdrom
    drives = vm['hardDrives'] = []
    drive = {'index': 1,
             'type': 'DISK',
             'name': 'root',
             'boot': True,
             'controller': args['disk_model'],
             'size': {'value': args['disk'][n]//(2**20), 'unit': 'MB'}}
    drives.append(drive)
    if args['cdrom']:
        drive = {'index': 2,
                 'type': 'CDROM',
                 'name': 'cdrom',
                 'controller': 'ide'}
        drives.append(drive)
        vm['bootOrder'] = ['CDROM', 'DISK']
    if args['image']:
        drives[-1]['baseDiskImageId'] = args['image']['id']
    # Supplied services.
    services = vm['suppliedServices'] = []
    for svc in args['service']:
        service = {'name': svc[0],
                   'portRange': svc[1],
                   'protocol': 'TCP',
                   'external': True}
        services.append(service)
    # Network interfaces
    connections = vm['networkConnections'] = []
    for ix,network in enumerate(args['network']):
        nic = {'index': ix,
               'deviceType': args['nic_model'],
               'useAutomaticMac': False,
               'mac': mac_ntoa(mac_aton(mac_base) + (ix<<16) + n)}
        ipcfg = {'hasPublicIp': args['inbound_access'][n] == 'publicip'}
        if network == 'dhcp':
            ipcfg['autoIpConfig'] = {}
        else:
            stcfg = {'ip': inet_ntoa(inet_aton(network[0]) + 10 + n),
                     'mask': network[1]}
            # First network interface gets a gateway, a DNS server, and it
            # hosts all the services.
            if ix == 0:
                stcfg['gateway'] = inet_ntoa(inet_aton(network[0]) + 1)
                stcfg['dns'] = stcfg['gateway']
                for svc in vm['suppliedServices']:
                    svc['ip'] = stcfg['ip']
            ipcfg['staticIpConfig'] = stcfg
        connection = {'name': 'eth{0}'.format(ix),
                      'device': nic,
                      'ipConfig': ipcfg}
        connections.append(connection)
    # CloudInit
    if args['flavor'] and args['flavor'].cloudinit:
        vm['supportsCloudInit'] = True
        cloudcfg = get_cc_user(args['default_user'])
        merge_cc(cloudcfg, args['flavor'].get_cc_network(vm))
        userdata = '#cloud-config\n'
        userdata += json.dumps(cloudcfg, indent=2)
        userdata += '\n'
        vm['configurationManagement'] = {'userData': userdata}
    return vm


def update_application(client, app, args):
    """Add all VMs to a (new or existing) application."""
    # Add all VMs and save updates.
    design = app.setdefault('design', {})
    vms = design.setdefault('vms', [])
    existing = set((vm['name'] for vm in vms))
    for i in range(args['count']):
        name = new_name('VM', existing)
        vm = create_vm(name, args, i)
        vms.append(vm)
    print('Added {0} VMs to application.'.format(args['count']))
    client.update_application(app)
    return app


def show_cloud_options(client, app, args):
    """Show possible publish locations for *app*."""
    if args['cloud'] != '?':
        return False
    try:
        options = client.get_application_publish_locations(app)
    except RavelloError:
        options = []
    print('Available cloud options:')
    if not options:
        print('  no suitable cloud found')
    for opt in options:
        print(" * '{0}', region: '{1}'".format(opt['cloudName'], opt['regionName']))
    return True


def publish_application(client, app, args):
    """Publish a new application, or publish updates to an existing app."""
    req = {'expirationFromNowSeconds': args['autostop'] or -1}
    client.set_application_expiration(app, req)
    published = app.get('published')
    if published:
        client.publish_application_updates(app, autostart=args['autostart'])
        what = 'application updates'
        whatvms = 'newly added VMs'
    else:
        req = {'startAllVms': args['autostart']}
        if args['cloud']:
            req['preferredCloud'] = args['cloud']
        if args['region']:
            req['preferredRegion'] = args['region']
        if args['optimization']:
            level = '{0}_OPTIMIZED'.format(args['optimization'].upper())
            req['optimizationLevel'] = level
        client.publish_application(app, req)
        what = 'application'
        whatvms = 'all VMs'
    if args['autostart']:
        print('Published {0}, started {1}.'.format(what, whatvms))
    else:
        print('Published {0}, not starting VMs.'.format(what))
    app = client.reload(app)
    return app


# Main program entry point

def main():
    debug = False

    try:
        args = docopt(__doc__)

        debug = bool(args['--debug'])
        logger = setup_logger(debug)

        args = parse_arguments(args)
        if show_help(args):
            return 0

        client = create_client(args)
        args = resolve_arguments(client, args)
        final_args_check(args)

        logger.debug('parsed args: %s', repr(args))

        app = create_application(client, args)
        update_application(client, app, args)

        if show_cloud_options(client, app, args):
            client.delete_application(app)
            return 0

        app = publish_application(client, app, args)

        logger.debug('published app: %s', repr(app))

    except Exception as e:
        if debug:
            raise
        sys.stdout.write('Error: {0!s}\n'.format(e))
        sys.exit(1)


if __name__ == '__main__':
    sys.exit(main())
