#!/usr/bin/env python3
"""
fr_distributed_cleanup.py - a script to remove hosts which have migrated to
other feeder hubs. To be run on Federated Reporting superhub
after each import of feeder data.

First, to setup, enable fr_distributed_cleanup by setting a class in augments (def.json).
This enables policy in cfe_internal/enterprise/federation/federation.cf

```json
{
  "classes": {
    "cfengine_mp_enable_fr_distributed_cleanup": [ "any::" ]
  }
}
```

After the policy has run on superhub and feeders, run this script
to setup fr_distributed_cleanup role and account on all feeders and superhubs with
proper RBAC settings for normal operation.
You will be prompted for superhub admin credentials and then
admin credentials on each feeder.
"""

import argparse
import logging
import os
import platform
import string
import random
import subprocess
import sys
from getpass import getpass
from nova_api import NovaApi
from cfsecret import read_secret, write_secret

WORKDIR = None
CFE_FR_TABLES = None
# get WORKDIR and CFE_FR_TABLES from config.sh
config_sh_path = os.path.join(os.path.dirname(__file__), "config.sh")
cmd = "source {}; echo $WORKDIR; echo $CFE_FR_TABLES".format(config_sh_path)
with subprocess.Popen(
    cmd, stdout=subprocess.PIPE, shell=True, executable="/bin/bash"
) as proc:
    lines = proc.stdout.readlines()
    WORKDIR = lines[0].decode().strip()
    CFE_FR_TABLES = [table.strip() for table in lines[1].decode().split()]

if not WORKDIR or not CFE_FR_TABLES:
    print("Unable to get WORKDIR and CFE_FR_TABLES values from config.sh")
    sys.exit(1)

# Primary dir in which to place various needed files
DISTRIBUTED_CLEANUP_DIR = "/opt/cfengine/federation/cftransport/distributed_cleanup"

# collect cert files from /var/cfengine/httpd/ssl/certs on
# superhub and feeders and cat all together into hubs.cert
CERT_PATH = os.path.join(DISTRIBUTED_CLEANUP_DIR, "hubs.cert")

# Note: remove the file at DISTRIBUTED_CLEANUP_SECRET_PATH to reset everything.
# api calls will overwrite fr_distributed_cleanup user and role on superhub and all feeders.
DISTRIBUTED_CLEANUP_SECRET_PATH = os.path.join(WORKDIR, "state/fr_distributed_cleanup.cfsecret")


def interactive_setup():
    fr_distributed_cleanup_password = "".join(random.choices(string.printable, k=20))
    admin_pass = getpass(
        prompt="Enter admin password for superhub {}: ".format(platform.node())
    )
    api = NovaApi(api_user="admin", api_password=admin_pass)

    # first confirm that this host is a superhub
    status = api.fr_hub_status()
    if (
        status["status"] == 200
        and status["role"] == "superhub"
        and status["configured"]
    ):
        logger.debug("This host is a superhub configured for Federated Reporting.")
    else:
        if status["status"] == 401:
            print("admin credentials are incorrect, try again")
            sys.exit(1)
        else:
            print(
                "Check the status to ensure role is superhub and configured is True. {}".format(
                    status
                )
            )
            sys.exit(1)

    feederResponse = api.fr_remote_hubs()
    if not feederResponse["hubs"]:
        print(
            "No attached feeders. Please attach at least one feeder hub before running this script."
        )
        sys.exit(1)

    email = input("Enter email for fr_distributed_cleanup accounts: ")

    logger.info("Creating fr_distributed_cleanup role on superhub...")
    response = api.put(
        "role",
        "fr_distributed_cleanup",
        {
            "description": "fr_distributed_cleanup Federated Host Cleanup role",
            "includeContext": "cfengine",
        },
    )
    if response["status"] != 201:
        print(
            "Problem creating fr_distributed_cleanup role on superhub. {}".format(
                response
            )
        )
        sys.exit(1)
    response = api.put_role_permissions(
        "fr_distributed_cleanup", ["query.post", "remoteHub.list", "hubStatus.get"]
    )
    if response["status"] != 201:
        print("Unable to set RBAC permissions on role fr_distributed_cleanup")
        sys.exit(1)
    logger.info("Creating fr_distributed_cleanup user on superhub")
    response = api.put(
        "user",
        "fr_distributed_cleanup",
        {
            "description": "fr_distributed_cleanup Federated Host Cleanup user",
            "email": "{}".format(email),
            "password": "{}".format(fr_distributed_cleanup_password),
            "roles": ["fr_distributed_cleanup"],
        },
    )
    if response["status"] != 201:
        print(
            "Problem creating fr_distributed_cleanup user on superhub. {}".format(
                response
            )
        )
        sys.exit(1)

    for hub in feederResponse["hubs"]:
        feeder_credentials = getpass(
            prompt="Enter admin credentials for {} at {}: ".format(
                hub["ui_name"], hub["api_url"]
            )
        )
        feeder_hostname = hub["ui_name"]
        feeder_api = NovaApi(
            api_user="admin",
            api_password=feeder_credentials,
            cert_path=CERT_PATH,
            hostname=feeder_hostname,
        )

        logger.info("Creating fr_distributed_cleanup role on %s", feeder_hostname)
        response = feeder_api.put(
            "role",
            "fr_distributed_cleanup",
            {
                "description": "fr_distributed_cleanup Federated Host Cleanup role",
                "includeContext": "cfengine",
            },
        )
        if response["status"] != 201:
            print(
                "Problem creating fr_distributed_cleanup role on superhub. {}".format(
                    response
                )
            )
            sys.exit(1)
        response = feeder_api.put_role_permissions(
            "fr_distributed_cleanup", ["host.delete"]
        )
        if response["status"] != 201:
            print("Unable to set RBAC permissions on role fr_distributed_cleanup")
            sys.exit(1)
        logger.info("Creating fr_distributed_cleanup user on %s", feeder_hostname)
        response = feeder_api.put(
            "user",
            "fr_distributed_cleanup",
            {
                "description": "fr_distributed_cleanup Federated Host Cleanup user",
                "email": "{}".format(email),
                "password": "{}".format(fr_distributed_cleanup_password),
                "roles": ["fr_distributed_cleanup"],
            },
        )
        if response["status"] != 201:
            print(
                "Problem creating fr_distributed_cleanup user on {}. {}".format(
                    feeder_hostname, response
                )
            )
            sys.exit(1)
        write_secret(DISTRIBUTED_CLEANUP_SECRET_PATH, fr_distributed_cleanup_password)


def main():
    if not os.geteuid() == 0:
        sys.exit("\n{} must be run as root".format(os.path.basename(__file__)))

    parser = argparse.ArgumentParser(
        description="Clean up migrating clients in Federated Reporting setup"
    )
    group = parser.add_mutually_exclusive_group()
    group.add_argument("--debug", action="store_true")
    group.add_argument("--inform", action="store_true")
    args = parser.parse_args()

    global logger
    logger = logging.getLogger("fr_distributed_cleanup")
    ch = logging.StreamHandler()
    if args.debug:
        logger.setLevel(logging.DEBUG)
        ch.setLevel(logging.DEBUG)
    if args.inform:
        logger.setLevel(logging.INFO)
        ch.setLevel(logging.INFO)
    logger.addHandler(ch)

    if not os.path.exists(DISTRIBUTED_CLEANUP_SECRET_PATH):
        if sys.stdout.isatty():
            interactive_setup()
        else:
            print(
                "{} requires manual setup, please run as root interactively.".format(
                    os.path.basename(__file__)
                )
            )
            sys.exit(1)

    fr_distributed_cleanup_password = read_secret(DISTRIBUTED_CLEANUP_SECRET_PATH)
    api = NovaApi(
        api_user="fr_distributed_cleanup", api_password=fr_distributed_cleanup_password
    )  # defaults to localhost
    response = api.fr_hub_status()
    if not (
        response["status"] == 200
        and response["role"] == "superhub"
        and response["configured"]
    ):
        print(
            "{} can only be run on a Federated Reporting hub configured to be superhub".format(
                os.path.basename(__file__)
            )
        )
        sys.exit(1)

    response = api.fr_remote_hubs()
    if not response["hubs"]:
        print(
            "No attached feeders. Please attach at least one feeder hub before running this script."
        )

    for hub in response["hubs"]:
        if hub["role"] != "feeder" or hub["target_state"] != "on":
            continue

        feeder_hostkey = hub["hostkey"]
        feeder_hostname = hub["ui_name"]
        feeder_api = NovaApi(
            api_user="fr_distributed_cleanup",
            api_password=fr_distributed_cleanup_password,
            cert_path=CERT_PATH,
            hostname=feeder_hostname,
        )
        response = feeder_api.status()
        if response["status"] != 200:
            print(
                "Unable to get status for feeder {}. Skipping".format(feeder_hostname)
            )
            continue

        sql = "SELECT hub_id FROM __hubs WHERE hostkey = '{}'".format(feeder_hostkey)
        response = api.query(sql)
        if response["status"] != 200:
            print("Unable to query for feeder hub_id. Response was {}".format(response))
            continue

        # query API should return one row, [0], and one column, [0], in rows value
        feeder_hubid = response["rows"][0][0]

        sql = """
SELECT DISTINCT hosts.hostkey
FROM hosts
WHERE hub_id = '{0}'
AND EXISTS(
  SELECT 1 FROM lastseenhosts ls
  JOIN (
    SELECT hostkey, max(lastseentimestamp) as newesttimestamp
    FROM lastseenhosts
    WHERE lastseendirection = 'INCOMING'
    GROUP BY hostkey
  ) as newest
  ON ls.hostkey = newest.hostkey
  AND ls.lastseentimestamp = newest.newesttimestamp
  AND ls.hostkey = hosts.hostkey
  AND ls.hub_id != '{0}'
)""".format(
            feeder_hubid
        )

        response = api.query(sql)
        if response["status"] != 200:
            print(
                "Unable to query for deletion candidates. Response was {}".format(
                    response
                )
            )
            sys.exit(1)
        logger.debug("Hosts to delete on %s are %s", hub["ui_name"], response["rows"])
        hosts_to_delete = response["rows"]
        if len(hosts_to_delete) == 0:
            logger.info("%s: No hosts to delete. No actions taken.", feeder_hostname)
            continue

        logger.debug(
            "%s host(s) to delete on feeder %s", len(hosts_to_delete), hub["ui_name"]
        )

        # build up a post-loop SQL statement to delete hosts locally from feeder schemas
        # change to feeder schema to make deletions easier/more direct without having to
        # specify hub_id in queries
        post_sql = "set schema 'hub_{}';\n".format(feeder_hubid)
        post_sql += "\\set ON_ERROR STOP on\n"
        delete_sql = ""
        post_hostkeys = []
        for row in hosts_to_delete:
            # The query API returns rows which are lists of column values.
            # We only selected hostkey so will take the first value.
            host_to_delete = row[0]

            response = feeder_api.delete("host", host_to_delete)
            # both 202 Accepted and 404 Not Found are acceptable responses
            if response["status"] not in [202, 404]:
                logger.warning(
                    "Delete %s on feeder %s got %s status code",
                    host_to_delete,
                    feeder_hostname,
                    response["status"],
                )
                continue

            # only add the host_to_delete if it was successfully deleted on the feeder
            post_hostkeys.append(host_to_delete)

        if len(post_hostkeys) == 0:
            logger.info(
                "No hosts on feeder %s need processing on superhub so skipping post processing",
                feeder_hostname,
            )
            continue

        # simulate the host api delete process by setting current_timestamp in deleted column
        # and delete from all federated tables similar to the clear_hosts_references() pgplsql function.
        post_sql += "INSERT INTO __hosts (hostkey,deleted) VALUES"
        for hostkey in post_hostkeys:
            delete_sql += "('{}', CURRENT_TIMESTAMP) ".format(hostkey)

        delete_sql += (
            "ON CONFLICT (hostkey,hub_id) DO UPDATE SET deleted = excluded.deleted;\n"
        )
        clear_sql = "set schema 'public';\n"
        for table in CFE_FR_TABLES:
            # special case of partitioning, operating on parent table will work
            if "__promiselog_*" in table:
                table = "__promiselog"
            clear_sql += (
                "DELETE FROM {} WHERE hub_id = {} AND hostkey IN ({});\n".format(
                    table,
                    feeder_hubid,
                    ",".join(["'{}'".format(hk) for hk in post_hostkeys]),
                )
            )
        post_sql += delete_sql + clear_sql

        logger.debug("Running SQL:\n%s", post_sql)
        with subprocess.Popen(
            ["/var/cfengine/bin/psql", "cfdb"],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
        ) as proc:
            logger.debug("got a proc, sending sql...")
            outs, errs = proc.communicate(input=post_sql.encode())
            if "ERROR" in errs.decode("utf-8"):
                print(
                    "Problem running post processing SQL. returncode was {}, stderr:\n{}\nstdout:\n{}".format(
                        proc.returncode, errs.decode("utf-8"), outs.decode("utf-8")
                    )
                )
                sys.exit(1)

            logger.debug(
                "Ran post processing SQL. returncode was %s, stderr:\n%s\nstdout:\n%s",
                proc.returncode,
                errs.decode("utf-8"),
                outs.decode("utf-8"),
            )

        if len(hosts_to_delete) != 0:
            logger.info(
                "%s: %s host deletions processed",
                hub["ui_name"],
                len(hosts_to_delete),
            )


if __name__ == "__main__":
    main()
else:
    raise ImportError("fr_distributed_cleanup.py must only be used as a script!")

[((58, 12, 58, 62), 'os.path.join', 'os.path.join', ({(58, 25, 58, 48): 'DISTRIBUTED_CLEANUP_DIR', (58, 50, 58, 61): '"""hubs.cert"""'}, {}), "(DISTRIBUTED_CLEANUP_DIR, 'hubs.cert')", False, 'import os\n'), ((62, 34, 62, 96), 'os.path.join', 'os.path.join', ({(62, 47, 62, 54): 'WORKDIR', (62, 56, 62, 95): '"""state/fr_distributed_cleanup.cfsecret"""'}, {}), "(WORKDIR, 'state/fr_distributed_cleanup.cfsecret')", False, 'import os\n'), ((40, 30, 40, 55), 'os.path.dirname', 'os.path.dirname', ({(40, 46, 40, 54): '__file__'}, {}), '(__file__)', False, 'import os\n'), ((42, 5, 44, 1), 'subprocess.Popen', 'subprocess.Popen', (), '', False, 'import subprocess\n'), ((51, 4, 51, 15), 'sys.exit', 'sys.exit', ({(51, 13, 51, 14): '(1)'}, {}), '(1)', False, 'import sys\n'), ((70, 10, 70, 60), 'nova_api.NovaApi', 'NovaApi', (), '', False, 'from nova_api import NovaApi\n'), ((203, 13, 205, 5), 'argparse.ArgumentParser', 'argparse.ArgumentParser', (), '', False, 'import argparse\n'), ((212, 13, 212, 56), 'logging.getLogger', 'logging.getLogger', ({(212, 31, 212, 55): '"""fr_distributed_cleanup"""'}, {}), "('fr_distributed_cleanup')", False, 'import logging\n'), ((213, 9, 213, 32), 'logging.StreamHandler', 'logging.StreamHandler', ({}, {}), '()', False, 'import logging\n'), ((233, 38, 233, 82), 'cfsecret.read_secret', 'read_secret', ({(233, 50, 233, 81): 'DISTRIBUTED_CLEANUP_SECRET_PATH'}, {}), '(DISTRIBUTED_CLEANUP_SECRET_PATH)', False, 'from cfsecret import read_secret, write_secret\n'), ((234, 10, 236, 5), 'nova_api.NovaApi', 'NovaApi', (), '', False, 'from nova_api import NovaApi\n'), ((66, 46, 66, 84), 'random.choices', 'random.choices', (), '', False, 'import random\n'), ((97, 8, 97, 19), 'sys.exit', 'sys.exit', ({(97, 17, 97, 18): '(1)'}, {}), '(1)', False, 'import sys\n'), ((116, 8, 116, 19), 'sys.exit', 'sys.exit', ({(116, 17, 116, 18): '(1)'}, {}), '(1)', False, 'import sys\n'), ((122, 8, 122, 19), 'sys.exit', 'sys.exit', ({(122, 17, 122, 18): '(1)'}, {}), '(1)', False, 'import sys\n'), ((140, 8, 140, 19), 'sys.exit', 'sys.exit', ({(140, 17, 140, 18): '(1)'}, {}), '(1)', False, 'import sys\n'), ((149, 21, 154, 9), 'nova_api.NovaApi', 'NovaApi', (), '', False, 'from nova_api import NovaApi\n'), ((196, 8, 196, 86), 'cfsecret.write_secret', 'write_secret', ({(196, 21, 196, 52): 'DISTRIBUTED_CLEANUP_SECRET_PATH', (196, 54, 196, 85): 'fr_distributed_cleanup_password'}, {}), '(DISTRIBUTED_CLEANUP_SECRET_PATH, fr_distributed_cleanup_password)', False, 'from cfsecret import read_secret, write_secret\n'), ((222, 11, 222, 58), 'os.path.exists', 'os.path.exists', ({(222, 26, 222, 57): 'DISTRIBUTED_CLEANUP_SECRET_PATH'}, {}), '(DISTRIBUTED_CLEANUP_SECRET_PATH)', False, 'import os\n'), ((223, 11, 223, 30), 'sys.stdout.isatty', 'sys.stdout.isatty', ({}, {}), '()', False, 'import sys\n'), ((248, 8, 248, 19), 'sys.exit', 'sys.exit', ({(248, 17, 248, 18): '(1)'}, {}), '(1)', False, 'import sys\n'), ((262, 21, 267, 9), 'nova_api.NovaApi', 'NovaApi', (), '', False, 'from nova_api import NovaApi\n'), ((83, 12, 83, 23), 'sys.exit', 'sys.exit', ({(83, 21, 83, 22): '(1)'}, {}), '(1)', False, 'import sys\n'), ((90, 12, 90, 23), 'sys.exit', 'sys.exit', ({(90, 21, 90, 22): '(1)'}, {}), '(1)', False, 'import sys\n'), ((171, 12, 171, 23), 'sys.exit', 'sys.exit', ({(171, 21, 171, 22): '(1)'}, {}), '(1)', False, 'import sys\n'), ((177, 12, 177, 23), 'sys.exit', 'sys.exit', ({(177, 21, 177, 22): '(1)'}, {}), '(1)', False, 'import sys\n'), ((195, 12, 195, 23), 'sys.exit', 'sys.exit', ({(195, 21, 195, 22): '(1)'}, {}), '(1)', False, 'import sys\n'), ((200, 11, 200, 23), 'os.geteuid', 'os.geteuid', ({}, {}), '()', False, 'import os\n'), ((231, 12, 231, 23), 'sys.exit', 'sys.exit', ({(231, 21, 231, 22): '(1)'}, {}), '(1)', False, 'import sys\n'), ((311, 12, 311, 23), 'sys.exit', 'sys.exit', ({(311, 21, 311, 22): '(1)'}, {}), '(1)', False, 'import sys\n'), ((379, 13, 384, 9), 'subprocess.Popen', 'subprocess.Popen', (), '', False, 'import subprocess\n'), ((68, 63, 68, 78), 'platform.node', 'platform.node', ({}, {}), '()', False, 'import platform\n'), ((201, 51, 201, 77), 'os.path.basename', 'os.path.basename', ({(201, 68, 201, 76): '__file__'}, {}), '(__file__)', False, 'import os\n'), ((245, 16, 245, 42), 'os.path.basename', 'os.path.basename', ({(245, 33, 245, 41): '__file__'}, {}), '(__file__)', False, 'import os\n'), ((393, 16, 393, 27), 'sys.exit', 'sys.exit', ({(393, 25, 393, 26): '(1)'}, {}), '(1)', False, 'import sys\n'), ((228, 20, 228, 46), 'os.path.basename', 'os.path.basename', ({(228, 37, 228, 45): '__file__'}, {}), '(__file__)', False, 'import os\n')]