# Copyright (c) 2016-2021 VMware, Inc. All rights reserved.
# VMware Confidential

"""
This file provides the hooks which will be invoked during patching process.
"""

import sys, os, logging, shutil
from distutils.version import LooseVersion

import sqlite3

from extensions import extend, Hook
import vcsa_utils
from fss_utils import getTargetFSS

try:
    sys.path.extend(["/var/lib/rbd/py", os.environ["VMWARE_PYTHON_PATH"]])
    import rbdversioninfo
    CURRENT_VERSION = rbdversioninfo.AUTODEPLOY_VERSION
except ModuleNotFoundError as error:
    # We are probably running on 7.0.0 which still has the frozen binaries
    logging.info("Failed to import rbdversioninfo: %s", error)
    CURRENT_VERSION = "7.0.0"

logger = logging.getLogger(__name__)
DB_PATH = "/var/lib/rbd/db"
CACHE_PATH = "/var/lib/rbd/cache"
VERSION_PATH = "/var/lib/rbd/.source_version"
"""A file containing the current autodeploy version

This will be created during the expand phase and will be replicated onto the
target machine.
"""

VITAL_CACHE_INTRODUCTION_VERSION = "7.0.2"
"""Version in which we added the vital cache."""

VITAL_PLUGINS_CLAUSE = '"vib", "script", "scriptbundle"'

GET_VITAL_CACHE_ITEMS_SQL = (
    "SELECT DISTINCT id, dst_dir, src_name "
    "FROM cache_item "
    "WHERE plugin IN ({plugins})"
).format(plugins=VITAL_PLUGINS_CLAUSE)

GET_NON_VITAL_CACHE_ITEMS_SQL = (
    "SELECT DISTINCT dst_dir, id "
    "FROM cache_item "
    "WHERE plugin NOT IN ({plugins})"
).format(plugins=VITAL_PLUGINS_CLAUSE)

DELETE_NON_VITAL_CACHE_SQL = (
    "DELETE FROM cache_item "
    "WHERE plugin NOT IN ({plugins})"
).format(plugins=VITAL_PLUGINS_CLAUSE)

DELETE_NON_VITAL_CACHE_FILES_SQL = (
    "DELETE FROM cache_item_file "
    "WHERE item_id IN ({items})"
)

UPDATE_CACHE_ITEM_SQL = (
    "UPDATE cache_item "
    "SET dst_dir = ? "
    "WHERE id = ?"
)


def _patchVitalCacheFiles(dbConnection):
    """Move non-recoverable files to the vital cache directory.

    In 67p05 and 7U2 we added designated subdirectory for cached item files
    that autodeploy cannot re-create itself. This function goes over the
    cache items for rbd plugins that are vital and moves them to the
    vital subdirectory if they are not already there.

    The plugins that may produce vital files are:
        - scriptbundle
        - vib

    @note: This method is idempotent.
    """
    # XXX: The requirements of the vCSA Non-disruptive upgrade (NDU) are
    # that the new service version must be able to work with the expanded
    # state from some older, compatible version of the service. Specifically for
    # this patch, it means that autodeploy will be up and running when it
    # needs to transition to the "vital" cache directory structure. So, we will
    # need to modify the cache while it is being used.
    # To handle that we do the transition in three steps:
    #   1. Copy the cache item dst_dir to the vital directory
    #   2. Update the cache item dst_dir in the DB
    #   3. Remove the old cache item dst_dir
    # The reason for not just moving the files is because the cache_tables
    # workflows will delete any cache item which lists files that do not exist.
    # I.e. if we just move the files, the cache_tables can delete the DB
    # entry before we can update it.
    logging.info("Patching vital cache")
    with dbConnection:
        cacheItemDirs = list(dbConnection.execute(GET_VITAL_CACHE_ITEMS_SQL))
        for dirRow in cacheItemDirs:
            sourceSubdir = dirRow[1]
            if not sourceSubdir.startswith("vital"):
                itemId = dirRow[0]
                itemName = dirRow[2]
                # From 03/0693e9ee176a335846d3576796d160
                # To   vital/030693e9ee176a335846d3576796d160
                destinationSubdir = "%s/%s" % (
                    "vital",
                    sourceSubdir.replace(os.path.sep, ""),
                )
                logger.info(
                    'Moving cache item "%s" to "%s"', itemName, destinationSubdir
                )
                try:
                    fromDir = os.path.join(CACHE_PATH, sourceSubdir)
                    toDir = os.path.join(CACHE_PATH, destinationSubdir)
                    shutil.copytree(fromDir, toDir)
                    while True:
                        try:
                            # Retry the update in case the DB is locked
                            dbConnection.execute(
                                UPDATE_CACHE_ITEM_SQL, (destinationSubdir, itemId)
                            )
                            break
                        except sqlite3.OperationalError as err:
                            if "locked" not in str(err):
                                shutil.rmtree(toDir)
                                raise
                    shutil.rmtree(fromDir)
                    # Don't try to delete XX from XX/yyyyyy if empty, the cacher
                    # will handle it on restart.
                except Exception as err:
                    logger.exception(
                        "Could not move vital cache item %s " "because of error: %s",
                        itemName,
                        err,
                    )


def _deleteNonVitalCache(dbConnection):
    """Delete all non-vital cache items."""
    logging.info("Deleting non-vital cache items")
    with dbConnection:
        cacheItemRows = list(dbConnection.execute(GET_NON_VITAL_CACHE_ITEMS_SQL))
        if not cacheItemRows:
            return
        nonVitalCacheItemDirs, nonVitalCacheItemIds = zip(*cacheItemRows)
        dbConnection.execute(DELETE_NON_VITAL_CACHE_SQL)
        deleteItemFilesClause = ",".join(str(i) for i in nonVitalCacheItemIds)
        dbConnection.execute(
            DELETE_NON_VITAL_CACHE_FILES_SQL.format(items=deleteItemFilesClause)
        )
    for cacheItemDirRow in nonVitalCacheItemDirs:
        cacheItemDir = cacheItemDirRow[0]
        try:
            shutil.rmtree(cacheItemDir)
        except:
            # At that point the cacher can be deleting files as well so just
            # ignore any errors here.
            pass


def _cleanupTlsConfig(dbConnection):
    """Delete the version file and revert the TLS config to the default.

    @see rbd.utils.sslconfig
    """
    logger.info("Reverting SSL config")
    try:
        os.remove(VERSION_PATH)
    except FileNotFoundError:
        pass

    from rbd.utils.sslconfig import defaultSSLProtocols
    sslProtocols = ",".join(defaultSSLProtocols)

    with dbConnection:
        dbConnection.execute(
            'INSERT OR REPLACE INTO config_pairs (key, value) VALUES ('
            '"ssl-protocols", ?)',
            (sslProtocols,)
        )


@extend(Hook.Discovery)
def discover(ctx):
    """Enroll Auto Deploy for the patch.

    Also add the Auto Deploy DB in the replication config.
    """
    logger.info("Auto Deploy discovered")

    if getTargetFSS("NDU_Limited_Downtime"):
        logger.info("NDU support enabled. DB will be replicated by the framework")
        # In a backup-restore workflow, we need to backup a "production" DB
        # so we use the sqlite3 utilites for online backup.
        # The NDU framework, however, uses a rsync to sync the DB in a
        # migration-based upgade. rsync finishes off the replication after the
        # services are stopped which means the DB will be up-to-date.
        replicationConfig = {
            DB_PATH: DB_PATH,
            VERSION_PATH: VERSION_PATH,
        }
        # XXX: The vital cache was introduced in 7.0.2, so if the source version
        # is anything before that, we need to copy the whole cache directory.
        # and not only the cache folder (which wouldn't exist anyway)
        if LooseVersion(CURRENT_VERSION) < LooseVersion(VITAL_CACHE_INTRODUCTION_VERSION):
            logger.info(
                "Source version is %s, replicating the full cache directory",
                CURRENT_VERSION,
            )
            replicationConfig.update(
                {
                    "/var/lib/rbd/cache/vital": None,
                    "/var/lib/rbd/cache": "/var/lib/rbd/cache",
                }
            )

        result = vcsa_utils.getComponentDiscoveryResult(
            "rbd", replicationConfig=replicationConfig
        )

        return result
    else:
        return vcsa_utils.getComponentDiscoveryResult("rbd")


@extend(Hook.Expand)
def expand(_ctx):
    """The autodeploy expand hook.

    Write the current (source) autodeploy version into a file so we can
    keep track if the upgrade happened or not.
    """
    with open(VERSION_PATH, "w") as versionFile:
        versionFile.write(CURRENT_VERSION)


@extend(Hook.Revert)
def revert(_ctx):
    """The autodeploy revert hook.

    Remove the version file.
    """
    try:
        os.remove(VERSION_PATH)
    except:
        pass


@extend(Hook.Patch)
def patch(ctx):
    """Perform the Auto Deploy patches.

    @deprecated hook.
    """
    if getTargetFSS("NDU_Limited_Downtime"):
        logger.info("NDU support enabled, skipping patch hook.")
        return

    logger.info("Patching Auto Deploy")
    try:
        conn = sqlite3.connect(DB_PATH)
        _cleanupTlsConfig(conn)
        _patchVitalCacheFiles(conn)
        _deleteNonVitalCache(conn)
        conn.close()
    except Exception as err:
        logger.exception("Failed to patch autodeploy: %s", err)


@extend(Hook.Contract)
def contract(ctx):
    """Contract unused state.
    - Move the vital chache files to the vital subdir. We canot do that
        in the expand phase as the cacher will delete files it doesn't
        know about.
    """
    logger.info("Starting Auto Deploy contract phase")
    try:
        conn = sqlite3.connect(DB_PATH)
        _cleanupTlsConfig(conn)
        _patchVitalCacheFiles(conn)
        _deleteNonVitalCache(conn)
        conn.close()
    except Exception as err:
        logger.exception("Failed to patch autodeploy: %s", err)
        raise
