#!/usr/bin/python3

# Copyright (c) 2022, Thomas Goirand <zigo@debian.org>
#           (c) 2022, Sylvain Didelot <sylvain.didelot@infomaniak.com>
#
# 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.

import configparser
import datetime
import os
import pathlib
import json
import random
import sys
from xml.dom import minidom

import guestfs
import libvirt

from oslo_config import cfg
from oslo_log import log as logging

LOG = logging.getLogger(__name__,project='ceilometer-intance-poller')

common_opts = [
    cfg.StrOpt('instance_info_cache_path',
               default='/var/cache/ceilometer-instance-poller',
               help='Path where to store the cache of instance polled information.'),
    cfg.IntOpt('instance_info_cache_expiration',
               default=240,
               help='Time in minute for the instance information cache expiration.'),
    cfg.IntOpt('instance_info_cache_fuzzing',
               default=120,
               help='Time in minute to randomly add to instance_info_cache_expiration.'
                    ' The point is to not poll all instances at the same time.'),
]

# Define your CLI-only options with boolean switches
cli_only_opts = [
    cfg.BoolOpt('use-cache-only',
                default=False,
                help='Only use cache data when available.'),
    cfg.BoolOpt('do-not-print',
                default=False,
                help='Do not print the output of found values. Useful to only put data in cache in a cron.'),
]

def list_opts():
    return [
        ("DEFAULT", common_opts),
    ]

conn = None


class Cache(datetime.timedelta):
    dir = None

    def __new__(cls, name, cache_path, *args, **kwargs):
        td = super().__new__(cls, *args, **kwargs)
        td.name = name
        td.dir = pathlib.Path(cache_path)
        dir = pathlib.Path(cache_path)
        return td

    def check(self):
        now = datetime.datetime.now(datetime.timezone.utc)

        try:
            with (self.dir / self.name).open() as f:
                cache = json.load(f)
            if datetime.datetime.fromisoformat(cache["expire"]) < now:
                return
            return cache["data"]
        except (FileNotFoundError, json.decoder.JSONDecodeError, KeyError, TypeError):
            return

    def store(self, data):
        now = datetime.datetime.now(datetime.timezone.utc)
        expire = now + self
        expire = expire.isoformat()

        cache = {"expire": expire, "data": data}
        with (self.dir / self.name).open("w") as f:
            json.dump(cache, f)


def _libvirt_connect():
    try:
        conn = libvirt.openReadOnly("qemu:///system")
    except libvirt.libvirtError as e:
        LOG.error("Could not connect to libvirt: %s" % repr(e))
        exit(1)
    return conn


def _list_all_domains(conn):

#    domainNames = conn.listDefinedDomains()
#    if domainNames == None:
#        print('Failed to get a list of domain names', file=sys.stderr)

    domainIDs = conn.listDomainsID()
    if domainIDs == None:
        LOG.error("Failed to get a list of domain IDs")
        exit(1)

    domains = []
    try:
        if len(domainIDs) != 0:
            for domainID in domainIDs:
                domains.append(conn.lookupByID(domainID))
    except libvirt.libvirtError as e:
        LOG.error("Could not list domains using libvirt: %s" % repr(e))
        conn.close()
        exit(1)

    return domains


def _libvirt_disconnect(conn):
    conn.close()


def _print_all(domains, expiration_timeout, cache_fuzzing, cache_path, use_cache_only, do_not_print):
    if len(domains) == 0:
        LOG.debug("Empty list of domains: no VMs?")
        print('[]')
    else:

        if os.path.isfile('/etc/ceph/ceph.client.openstack.keyring'):
            # Read the Ceph openstack client key, that we need
            # further down when doing guestfs.GuestFS.add_drive()
            cephclientconfig = configparser.ConfigParser()
            cephclientconfig.read('/etc/ceph/ceph.client.openstack.keyring')
            cephkey = cephclientconfig['client.openstack']['key']

        if os.path.isfile('/etc/ceph/ceph.conf'):
            # Get the list of mon hosts from ceph.conf
            cephconfig = configparser.ConfigParser()
            cephconfig.read('/etc/ceph/ceph.conf')
            cephmons = cephconfig['global']['mon_host']
            cephmonarray = [c+':6789' for c in cephmons.split(',')]

        metrics = []
        for domain in domains:
            instance_uuid = domain.UUIDString()
            instance_vcpus = domain.maxVcpus()
            LOG.debug(f"Found instance {instance_uuid} ({instance_vcpus} vcpus)")
            if use_cache_only:
                exp = (expiration_timeout + cache_fuzzing) * 2
            else:
                exp = expiration_timeout + random.randrange(cache_fuzzing)
            cache = Cache(instance_uuid, cache_path, minutes=exp)
            metric = cache.check()

            if metric is None and not use_cache_only:
                LOG.debug(f"No cahe for instance {instance_uuid}: will fetch the data using libgestfs.")
                g = guestfs.GuestFS(python_return_dict=True)

#               The naive way is to replace what's below
#               including all of the for disk in disks loop
#               by a g.add_libvirt_dom(). This unfortunately
#               cannot work with a Ceph backend, because there
#               is no way to provide the Ceph auth credentials
#               with this function: libguestfs simply doesn't
#               have this feature.
#               So instead, we parse all of the domain's XML
#               and add each disk one by one.
#               g.add_libvirt_dom(domain, readonly=True)

                raw_xml = domain.XMLDesc()
                xml = minidom.parseString(raw_xml)
                disks = xml.getElementsByTagName("disk")

                for disk in disks:

                    disk_type = disk.getAttribute('type')
                    disk_device = disk.getAttribute('device')
                    disksources = disk.getElementsByTagName('source')
                    for disksource in disksources:
                        disk_targets = disk.getElementsByTagName('target')
                        for disk_target in disk_targets:
                            disk_target_dev = disk_target.getAttribute('dev')
                        disk_name = '/dev/' + disk_target_dev
                        # This should be a Ceph-based nova-compute
                        if disk_type == 'network':
                            disksource_name = disksource.getAttribute('name')
#                            Example call with a Ceph drive bellow:
#                            g.add_drive(filename='cinder/volume-bcfaf249-f69f-4c8e-829a-47e960265bcf',
#                                        readonly=True,
#                                        name='/dev/sda',
#                                        protocol='rbd',
#                                        server=['10.0.0.21:6789', '10.0.0.22:6789', '10.0.0.23:6789'],
#                                        username='openstack',
#                                        secret=AQAUaHteKPhnKhAAyLFTM39AnbIGfAbqQSoUYw==,
#                                        copyonread=True,
#                           )
                            # If it's not a cinder volume, then we're in Nova's case,
                            # so we use 'normal' /etc/ceph/ceph.conf and
                            # /etc/ceph/ceph.client.openstack.keyring
                            if 'cinder/' not in disksource_name:
                                g.add_drive(filename=disksource_name,
                                            format="raw",
                                            readonly=True,
                                            name=disk_name,
                                            protocol='rbd',
                                            server=cephmonarray,
                                            username='openstack',
                                            secret=cephkey,
                                            copyonread=True,
                                )
                            else:
                                # We first try our normal AZ, if the disk isn't cross-az attached
                                try:
                                    g.add_drive_opts(filename=disksource_name,
                                                     format="raw",
                                                     readonly=True,
                                                     name=disk_name,
                                                     protocol='rbd',
                                                     server=cephmonarray,
                                                     username='openstack',
                                                     secret=cephkey,
                                                     copyonread=True,
                                    )
                                except:
                                    # If not, we try each AZ one by one. This is probably a bit stupid,
                                    # but it works and it's kind of fast, so let's do that.
                                    for az in ['az1', 'az2', 'az3']:
                                        LOG.info(f"---> Trying az {az}")
                                        az_cephclientconfig_path = '/etc/ceph/ceph-' + az + '.client.openstack.keyring'
                                        az_cephconf_path = '/etc/ceph/ceph-' + az + '.conf'
                                        if os.path.isfile(az_cephclientconfig_path):
                                            # Read the Ceph openstack client key, that we need
                                            # further down when doing guestfs.GuestFS.add_drive()
                                            az_cephclientconfig = configparser.ConfigParser()
                                            az_cephclientconfig.read(az_cephclientconfig_path)
                                            az_cephkey = az_cephclientconfig['client.openstack']['key']

                                        if os.path.isfile(az_cephconf_path):
                                            # Get the list of mon hosts from ceph.conf
                                            az_cephconfig = configparser.ConfigParser()
                                            az_cephconfig.read(az_cephconf_path)
                                            az_cephmons = az_cephconfig['global']['mon_host']
                                            az_cephmonarray = [c+':6789' for c in az_cephmons.split(',')]

                                        try:
                                            g.add_drive_opts(filename=disksource_name,
                                                             format="raw",
                                                             readonly=True,
                                                             name=disk_name,
                                                             protocol='rbd',
                                                             server=az_cephmonarray,
                                                             username='openstack',
                                                             secret=az_cephkey,
                                                             copyonread=True,
                                            )
                                            LOG.info(f"---> Could mount disk in {az}")
                                            break
                                        except:
                                            LOG.info(f"---> Failed to mount disk in {az}")
                                            continue
                        # This must be a non-Ceph based nova-compute
                        elif disk_type == 'file':
                            disksource_filename = disksource.getAttribute('file')
                            g.add_drive(filename=disksource_filename,
                                        format='raw',
                                        readonly=True,
                                        name=disk_name,
                                        protocol='file',
                                        copyonread=True,
                            )

                g.launch()
                # Sometimes, inspect_os fails, so we just accept this fact...
                try:
                    roots = g.inspect_os()
                except:
                    roots = []

                # If it's empty, it means guestfs couldn't find anything,
                # maybe because of an UFS filesystem, for example.
                if roots == []:
                    metric = {
                        "resource_id" : domain.UUIDString(),
                        "vcpus": domain.maxVcpus(),
                        "os_family": 'unknown',
                        "os_distro": 'unknown',
                        "os_info": 'unknown',
                        "os_version_major": 0,
                        "os_version_minor": 0,
                        "os_product_name": 'unknown',
                        "os_product_variant": 'unknown',
                    }

                else:
                    for root in roots:
                        metric = {
                            "resource_id" : domain.UUIDString(),
                            "vcpus": domain.maxVcpus(),
                            "os_family": g.inspect_get_type(root),
                            "os_distro": g.inspect_get_distro(root),
                            "os_info": g.inspect_get_osinfo(root),
                            "os_version_major": g.inspect_get_major_version(root),
                            "os_version_minor": g.inspect_get_minor_version(root),
                            "os_product_name": g.inspect_get_product_name(root),
                            "os_product_variant": g.inspect_get_product_variant(root),
                        }
                g.umount_all()
                g.close()
                cache.store(metric)

            else:
                LOG.debug(f"Using cache for instance {instance_uuid}")

            if metrics != None and metric != None:
                metrics.append(metric)


        if do_not_print:
            return metrics
        else:
            print(json.dumps(metrics))
            return metrics

def run_instance_poller():
    conf = cfg.ConfigOpts()
    conf.register_opts(common_opts)
    conf.register_cli_opts(cli_only_opts)
    logging.register_options(conf)
    conf(args=sys.argv[1:], project='ceilometer-instance-poller')
    logging.setup(conf, conf.project)


    conn = _libvirt_connect()
    domains = _list_all_domains(conn)
    _print_all(domains, conf.instance_info_cache_expiration,
                        conf.instance_info_cache_fuzzing,
                        conf.instance_info_cache_path,
                        conf.use_cache_only,
                        conf.do_not_print)
    _libvirt_disconnect(conn)

if __name__ == "__main__":
    run_instance_poller()
    exit(0)
