#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Program to create an ansible inventory from all the deployments, jobs and
instances managed by a BOSH Director.
"""
# Python 2 and 3 compatibility
from __future__ import unicode_literals, print_function

__program__ = "bosh-inventory"
__version__ = "0.4.4"
__author__ = "Jose Riguera"
__year__ = "2017"
__email__ = "<jose.riguera@springer.com>"
__license__ = "MIT"
__purpose__ = """
bosh-inventory reads BOSH_CONFIG environment variable pointing to the configuration
file used by Bosh client (defaults to '~/.bosh/config' or '~/.bosh_config') in
order to get the credentials from it. You can define additional inventory
parameters via BOSH_ANSIBLE_INVENTORY_PARAMS environment variable, for example:
BOSH_ANSIBLE_INVENTORY_PARAMS="ansible_user=vcap ansible_ssh_pass=blabla". Be aware
that maybe Python2 (required by ansible) is not present in the default location,
but you can use this variable to specify "ansible_python_interpreter=/path/to/python".
If you use Bosh cli v2, you also will need to define the variable
BOSH_ENVIRONMENT in order to targe the correct director.

The environment variable BOSH_ANSIBLE_INVENTORY_VARS defines a list of
entries which can appear as inventory variables for each VM. The list of values
you can use is here: https://bosh.io/docs/director-api-v1.html#list-instances-detailed,
for example BOSH_ANSIBLE_INVENTORY_VARS="state bootstrap" will add
"state=started bootstrap=false" to each inventory entry.

The environment variable BOSH_ANSIBLE_INVENTORY_INSTANCES, defines the VM name
will have in the inventory. If it 'dns' it will build the inventory entry with
the dns names given by Bosh Director (if dns is enabled in Bosh, otherwise it
will fallback to autogenerated names and include the ip address for each
instance), if 'vm_cid' (default) it will be using the name of the VM as it is
in the IaaS. You can see all parameters supported in
https://bosh.io/docs/director-api-v1.html#list-instances-detailed.
To force always the inclusion of the IP in the inventory,  just define the
variable BOSH_ANSIBLE_INVENTORY_IP as a positive integer indicating the index 
(starting from 1) of the IP which will be taken (for VMs with multiple IPs),
0 disabled the feature. The default value for BOSH_ANSIBLE_INVENTORY_IP is 1.

BOSH_ANSIBLE_INVENTORY_CALL defines the way the inventoy is populated. It can
be 'instances'(default) or 'vms'. Instances is faster because it does not query
the Bosh Agents, so it gets the desired state. 'vms' will query the Bosh Agents
in order to get the current state, so depending on the situation, it can take a
lot of time to get the result.

You can also limit the inventory to one specific deployment by setting the value
of the environment variable BOSH_ANSIBLE_DEPLOYMENT to the name of it.
"""
import sys
import os
import time
import argparse
import json
import yaml
import requests
from collections import OrderedDict
try:
    # Python 3.x
    from io import StringIO
except ImportError:
    # Python 2.x
    #from StringIO import StringIO
    from io import BytesIO as StringIO
try:
    # Python 3.x
    from urllib.parse import urlparse
except ImportError:
    # Python 2.x
    from urlparse import urlparse
# Disable HTTPS warning with self signed certs
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)



def get_instances(session, api, deployment, method, timeout=60):
    result = []
    instances_url = api + '/deployments/{name}/' + str(method)
    instances_url = instances_url.format(**deployment)
    instances_req = session.get(instances_url,
        params={'format': 'full'},
        allow_redirects=False)
    if instances_req.status_code != 302:
        return result
    task_url = api + urlparse(instances_req.headers['Location']).path
    status = '-'
    while status != 'done':
        instances_status_req = session.get(task_url)
        if instances_status_req.status_code != 200:
            break
        status = instances_status_req.json()['state']
        if timeout > 0:
            timeout -= 1
            time.sleep(1)
        else:
            break
    else:
        output_url =  task_url + '/output'
        instances_out_req = session.get( output_url,
            headers={'Accept': 'text/plain'},
            params={'type': 'result'})
        if instances_out_req.status_code == 200:
            for line in instances_out_req.text.splitlines():
                result.append(json.loads(line))
    return result


def get_deployments(session, api):
    result = []
    deployments_url = api + '/deployments'
    deployments_req = session.get(deployments_url)
    if deployments_req.status_code == 200:
        result = deployments_req.json()
    return result


def logger(msg, level="WARNING"):
    print(level + ": " + msg, file=sys.stderr)


def get_instance_inventory_name(instance, instance_name_key, job):
    # With ip, where ip represents an index in the list
    # If it is zero, it will be disabled disabled.
    try:
        if isinstance(instance[instance_name_key], list):
            entry = instance[instance_name_key][0]
        else:
            entry = instance[instance_name_key]
    except Exception as e:
        logger("Could not get name for %s" % (instance['vm_cid'], str(e)))
        entry = job + '-' + str(instance['index'])
    return entry


def get_instance_inventory_ip(instance, index=0):
    try:
        ip = instance['ips'][index - 1]
    except:
        logger("IP index out of range (%s) for %s" % (index - 1, instance['vm_cid']))
        ip = instance['ips'][0]
    return ip


def create_inventory(session, api, target_deployment=None, ip=1,
                     inventory_name_key='dns', variables=[], params=[], method="instances"):
    deployments = get_deployments(session, api)
    inventory = OrderedDict()
    inventory["_meta"] = {}
    inventory["_meta"]["hostvars"] = {}
    deployments_list = []
    for deployment in deployments:
        name = deployment['name']
        if target_deployment and target_deployment != name:
            continue
        instances = get_instances(session, api, deployment, method)
        inventory[name] = {}
        inventory[name]["children"] = []
        deployments_list.append(name)
        for instance in instances:
            job = instance['job_name']
            if job in deployments_list:
                # Ops, we hava a job with the same name as another deployment!
                # Rename the job
                job = "%s-%s" % (job, name)
            # check if the job has a proper vm
            if instance['vm_cid'] != None:
                if job not in inventory:
                    inventory[job] = {}
                    inventory[job]["children"] = []
                    inventory[name]["children"].append(job)
                if not 'hosts' in inventory[job]:
                    inventory[job]["hosts"] = []
                entry = get_instance_inventory_name(instance, inventory_name_key, job)
                inventory["_meta"]["hostvars"][entry] = {}
                if ip:
                    entry_ip = get_instance_inventory_ip(instance, ip)
                    inventory["_meta"]["hostvars"][entry]['ansible_host'] = entry_ip
                inventory[job]["hosts"].append(entry)
                for variable in variables:
                    try:
                        inventory["_meta"]["hostvars"][entry][str(variable)] = str(instance[variable])
                    except:
                        logger("Could not add variable '%s' to %s" % (variable, instance['vm_cid']))
                for item in params:
                    param = item.split('=')
                    inventory["_meta"]["hostvars"][entry][param[0]] = param[1]
    return json.dumps(inventory, sort_keys=False, indent=2)


def create_ini(session, api, target_deployment=None, ip=1,
               inventory_name_key='dns', variables=[], params=[], method="instances"):
    deployments = get_deployments(session, api)
    inventory = OrderedDict()
    inventory["[all:children]"] = []
    deployments_list = []
    for deployment in deployments:
        name = deployment['name']
        if target_deployment and target_deployment != name:
            continue
        instances = get_instances(session, api, deployment, method)
        inventory["[all:children]"].append(name)
        inventory["[%s:children]" % name] = []
        deployments_list.append(name)
        for instance in instances:
            job = instance['job_name']
            if job in deployments_list:
                # Ops, we hava a job with the same name as another deployment!
                # Rename the job
                job = "%s-%s" % (job, name)
            job_key = "[%s]" % job
            # check if the job has a proper vm (it skips the errands)
            if instance['vm_cid'] != None:
                if job_key not in inventory:
                    inventory[job_key] = []
                    inventory["[%s:children]" % name].append(job)
                entry = get_instance_inventory_name(instance, inventory_name_key, job)
                if ip:
                    entry_ip = get_instance_inventory_ip(instance, ip)
                    entry = entry + ' ansible_host=' + entry_ip
                for variable in variables:
                    try:
                        entry = entry + " %s='%s'" % (variable, instance[variable])
                    except:
                        logger("Could not add variable '%s' to %s" % (variable, instance['vm_cid']))
                entry = entry + ' ' + ' '.join(params)
                inventory[job_key].append(entry)
    output = StringIO()
    for key in inventory:
        items = inventory[key]
        print(key, file=output)
        for item in inventory[key]:
            print(item, file=output)
        print('', file=output)
    content = output.getvalue()
    output.close()
    return content


def bosh_config_credentials(cfgfiles=['~/.bosh/config', '~/.bosh_config']):
    credentials = {}
    try:
        if os.getenv('BOSH_CONFIG'):
            cfgfiles = [os.getenv('BOSH_CONFIG')]
        for cfgfile in cfgfiles:
            bosh_config_file = os.path.expandvars(os.path.expanduser(cfgfile))
            if os.path.isfile(bosh_config_file):
                break
        else:
            raise ValueError("Cannot find Bosh config file '%s'" % (bosh_config_file))
        with open(bosh_config_file, 'r') as stream:
            bosh_config = yaml.load(stream)
    except Exception as e:
        raise ValueError("Cannot load Bosh config file '%s': %s" % (bosh_config_file, str(e)))
    target = os.getenv('BOSH_ENVIRONMENT')
    if 'environments' in bosh_config:
        # bosh cli v2
        if not target:
            raise ValueError("No target specified via BOSH_ENVIRONMENT env variable")
        environment = None
        for e in bosh_config['environments']:
            if target == e['url']:
                environment = e
                break
        else:
            raise ValueError("No target found in '%s'" % (bosh_config_file))
        credentials['target'] = environment['url']
        credentials['ca_cert'] = None
        credentials['username'] = environment['username']
        credentials['password'] = environment['password']
    else:
        if target:
            try:
                env = bosh_config['auth'][target]
            except:
                raise ValueError("Bosh target not found: %s" % (target))
        else:
            env = bosh_config['target']
        credentials['target'] = env
        credentials['ca_cert'] = bosh_config['ca_cert'][env]
        credentials['username'] = bosh_config['auth'][env]['username']
        credentials['password'] = bosh_config['auth'][env]['password']
    env_url = requests.packages.urllib3.util.parse_url(credentials['target'])
    env_port = env_url.port if env_url.port else 25555
    env_proto = env_url.scheme if env_url.scheme else 'https'
    credentials['target'] = "%s://%s:%d" % (env_proto, env_url.host, env_port)
    return credentials


def main():
    # Argument parsing
    epilog = __purpose__ + '\n'
    epilog += __version__ + ', ' + __year__ + ' '
    epilog += __author__ + ' ' + __email__
    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawTextHelpFormatter,
        description=__doc__, epilog=epilog)
    parser.add_argument(
        '--list', action='store_true', default=False,
         help='Enable JSON output for dynamic ansible inventory')
    args = parser.parse_args()
    try:
        credentials = bosh_config_credentials()
    except Exception as e:
        logger(str(e), "ERROR")
        sys.exit(1)
    target = credentials['target']
    ca_cert = credentials['ca_cert']
    username = credentials['username']
    password = credentials['password']
    # Read other parameters for the inventory
    inventory_params = os.getenv('BOSH_ANSIBLE_INVENTORY_PARAMS', '').split()
    inventory_variables = os.getenv('BOSH_ANSIBLE_INVENTORY_VARS', '').split()
    bosh_method = os.getenv('BOSH_ANSIBLE_INVENTORY_CALL', 'instances')
    if bosh_method not in ['instances', 'vms']:
        logger("BOSH_ANSIBLE_INVENTORY_CALL must be 'instances' or 'vms'", "ERROR")
        sys.exit(1)
    target_deployment = os.getenv('BOSH_ANSIBLE_DEPLOYMENT', None)
    try:
        inventory_ip = abs(int(os.getenv('BOSH_ANSIBLE_INVENTORY_IP', '1')))
    except:
        logger("BOSH_ANSIBLE_INVENTORY_IP must be positive integer, 0 to disable", "ERROR")
        sys.exit(1)
    try:
        inventory_instances = os.getenv('BOSH_ANSIBLE_INVENTORY_INSTANCES', 'vm_cid')
    except:
        logger("BOSH_ANSIBLE_INVENTORY_INSTANCES must be 'dns', 'vm_cid' ...")
        inventory_instances = 'dns'
    # create a session
    session = requests.Session()
    session.auth = (username, password)
    session.verify = True if ca_cert else False
    session.cert = ca_cert if ca_cert else None
    # Doing the job
    if args.list:
        print(create_inventory(
            session, target, target_deployment, inventory_ip,
            inventory_instances, inventory_variables, inventory_params,
            bosh_method)
        )
    else:
        print(create_ini(
            session, target, target_deployment, inventory_ip, 
            inventory_instances, inventory_variables, inventory_params,
            bosh_method)
        )
    sys.exit(0)


if __name__ == "__main__":
    main()

