#!/usr/bin/python
#
# Utility for setting up ISS master/slave org-mappings
#
# Copyright (c) 2013--2017 Red Hat, Inc.
#
#
# This software is licensed to you under the GNU General Public License,
# version 2 (GPLv2). There is NO WARRANTY for this software, express or
# implied, including the implied warranties of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2
# along with this software; if not, see
# http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt.
#
# Red Hat trademarks are not licensed under GPLv2. No permission is
# granted to use or replicate Red Hat trademarks that are incorporated
# in this software or its documentation.
#

""" iss_setup - a tool for easing the pain of setting up organization-mappings for ISS """

import logging
import os
import sys
import xmlrpclib
import string
from string import lower
from ConfigParser import SafeConfigParser
from optparse import OptionParser, OptionGroup
from os import path, access, R_OK
from os.path import expanduser
import getpass

CONF_DIR = os.path.expanduser('~/.spacewalk-sync-setup')
USER_CONF_FILE = os.path.join(CONF_DIR, 'config')
DEFAULT_MASTER_SETUP_FILENAME = os.path.join(CONF_DIR, 'master.txt')
DEFAULT_SLAVE_SETUP_FILENAME = os.path.join(CONF_DIR, 'slave.txt')
UNKNOWN_FQDN = 'UNKNOWN-FQDN'

DEFAULT_CONFIG = """
# Default defines the slave and/or master we should connect to by default
[Default]
# Default slave FQDN
slave.default=SLAVE
# Default master FQDN
master.default=MASTER
"""

DEFAULT_CONFIG_FQDN_STANZA = """
# Any spacewalk Fully-Qualified-Domain-Name (fqdn) can have a stanza,
# defining logins and setup files to use
[FQDN]
# Login of a sat-admin login for this instance
login = LOGIN
# NOTE: Putting passwords in cfg-files is suboptimal.  The tool will ask
# But if you really want to, go ahead
password = PASSWORD
# Where's the slave-side setup file for this spacewalk instance?
slave.setup = SLAVE_FILE
# Where's the master-side setup file for this spacewalk instance?
master.setup = MASTER_FILE
"""


def setupOptions():
    usage = 'usage: %prog [options]'
    parser = OptionParser(usage=usage)

    cnxGrp = OptionGroup(parser, "Connections", "Identify the spacewalk instances we're going to connect to")
    cnxGrp.add_option('--ss', '--slave-server', action='store', dest='slave', default=UNKNOWN_FQDN,
                      metavar='SLAVE-FQDN',
                      help="name of a slave to connect to.")
    cnxGrp.add_option('--sl', '--slave-login', action='store', dest='slave_login', default="",
                      metavar='LOGIN',
                      help="A sat-admin login for slave-server")
    cnxGrp.add_option('--sp', '--slave-password', action='store', dest='slave_password', default="",
                      metavar='PASSWORD',
                      help="Password for login slave-login on slave-server")
    cnxGrp.add_option('--ms', '--master-server', action='store', dest='master', default=UNKNOWN_FQDN,
                      metavar='MASTER-FQDN',
                      help="name of a master to connect to.")
    cnxGrp.add_option('--ml', '--master-login', action='store', dest='master_login', default="",
                      metavar='LOGIN',
                      help="A sat-admin login for master-server")
    cnxGrp.add_option('--mp', '--master-password', action='store', dest='master_password', default="",
                      metavar='PASSWORD',
                      help="Password for login master-login on master-server")
    cnxGrp.add_option('--md', '--master-default', action='store_true', dest='master_default', default=False,
                      help="Should the specified master be made the default-master in a specified template-file")
    parser.add_option_group(cnxGrp)

    tmplGrp = OptionGroup(parser, "Templates",
                          "Options for creating initial versions of setup files\n"
                          "NOTE: This will replace existing machine-specific stanzas with new content")
    tmplGrp.add_option('--cst', '--create-slave-template', action='store_true', dest='slave_template', default=False,
                       help="Create/update a setup file containing a stanza for the slave we're pointed at, "
                       "based on information from the master we're pointed at")
    tmplGrp.add_option('--cmt', '--create-master-template', action='store_true', dest='master_template', default=False,
                       help="Create/update a setup file stanza for the master we're pointed at, "
                       "based on information from the slave we're pointed at")
    tmplGrp.add_option('--ct', '--create-templates', action='store_true', dest='both_template', default=False,
                       help="Create both a master and a slave setup file, for the master/slave pair we're pointed at")
    parser.add_option_group(tmplGrp)

    setupGrp = OptionGroup(parser, "Setup",
                           "Specify the setup files we're actually going to apply to a slave/master")
    setupGrp.add_option('--msf', '--master-setup-file', action='store', dest='master_file', metavar='FILE',
                        default=DEFAULT_MASTER_SETUP_FILENAME,
                        help='Specify the master-setup-file we should use')
    setupGrp.add_option('--ssf', '--slave-setup-file', action='store', dest='slave_file', metavar='FILE',
                        default=DEFAULT_SLAVE_SETUP_FILENAME,
                        help='Specify the slave-setup-file we should use')
    parser.add_option_group(setupGrp)

    actionGrp = OptionGroup(parser, "Action", "Should we actually affect the specified spacewalk instances?")
    actionGrp.add_option('--dt', '--describe-templates', action='store_true', dest='describe_templates', default=False,
        help="Describe existing templates for master and slave hosts.")
    actionGrp.add_option('--apply', action='store_true', dest='apply', default=False,
        help="make the changes specified by the setup files to the specified spacewalk instances")
    actionGrp.add_option('--ch', '--configured-hosts', action='store_true', dest='configured_hosts', default=False,
        help="Use all configured hosts from the default configuration if not explicitly specified.")
    parser.add_option_group(actionGrp)

    utilGrp = OptionGroup(parser, "Utility")
    utilGrp.add_option('-d', '--debug', action='store_true', default=False, dest='debug',
                       help='Log debugging output')
    utilGrp.add_option('-q', '--quiet', action='store_true', default=False, dest='quiet',
                       help='Log only errors')
    parser.add_option_group(utilGrp)

    return parser


def setupLogging(opt):
    # determine the logging level
    if opt.debug:
        level = logging.DEBUG
    elif opt.quiet:
        level = logging.ERROR
    else:
        level = logging.INFO

    # configure logging
    logging.basicConfig(level=level, format='%(levelname)s: %(message)s')
    return


def initializeConfig(opt, handle):
    "We don't have any defaults - create some, using CLI if we have them"
    hdr = DEFAULT_CONFIG

    master_stanza = DEFAULT_CONFIG_FQDN_STANZA
    slave_stanza = DEFAULT_CONFIG_FQDN_STANZA
    master = opt.master and opt.master or ask("Fully qualified domain name for master")
    hdr = hdr.replace('MASTER', master)
    master_stanza = master_stanza.replace('FQDN', master)

    login = opt.master_login and opt.master_login or ask("Admin login for %s" % master)
    master_stanza = master_stanza.replace('LOGIN', login)

    password = opt.master_password and opt.master_password or ask("Password for %s" % master, password=True)
    master_stanza = master_stanza.replace('PASSWORD', password)

    if opt.master_file:
        master_stanza = master_stanza.replace('MASTER_FILE', opt.master_file)

    slave = opt.slave and opt.slave or ask("Fully qualified domain name for slave")
    hdr = hdr.replace('SLAVE', slave)
    slave_stanza = slave_stanza.replace('FQDN', slave)

    login = opt.slave_login and opt.slave_login or ask("Admin login for %s" % slave)
    slave_stanza = slave_stanza.replace('LOGIN', login)

    password = opt.slave_password and opt.slave_password or ask("Password for %s" % slave, password=True)
    slave_stanza = slave_stanza.replace('PASSWORD', password)

    if opt.slave_file:
        slave_stanza = slave_stanza.replace('SLAVE_FILE', opt.slave_file)

    logging.debug("Header is now " + hdr)
    logging.debug("Slave-stanza is now " + slave_stanza)
    logging.debug("Master-stanza is now " + master_stanza)

    handle.write(hdr)
    handle.write(slave_stanza)
    handle.write(master_stanza)

    return


def setupConfig(opt):
    "The cfg-values we recognize include: \n"
    "  * default master \n"
    "  * default slave \n"
    "  * For specific FQDNs: \n"
    "    * login \n"
    "    * password \n"
    "    * master-setup-file \n"
    "    * slave-setup-file \n"

    # server-specifics will be loaded from the configuration file later
    config = SafeConfigParser()

    # create an empty configuration file if one's not present
    if not os.path.isfile(USER_CONF_FILE):
        try:
            # create ~/.spacewalk-sync-setup
            if not os.path.isdir(CONF_DIR):
                logging.debug('Creating %s' % CONF_DIR)
                os.mkdir(CONF_DIR, 0700)

            # create a template configuration file
            logging.debug('Creating configuration file: %s' % USER_CONF_FILE)
            handle = open(USER_CONF_FILE, 'w')
            initializeConfig(opt, handle)
            handle.close()
        except IOError:
            logging.error('Could not create %s' % USER_CONF_FILE)

    # load options from configuration file
    config.read([USER_CONF_FILE])

    return config


def getMasterConnectionInfo(opt, cfg):
    "Make sure we have login, password, and fqdn for MASTER, based on options and config-files"
    info = {}

    info['debug'] = opt.debug

    if 'master' in opt.__dict__:
        info['fqdn'] = opt.master
    elif cfg.has_option('Default', 'master.default'):
        info['fqdn'] = cfg.get('Default', 'master.default')
    else:  # No master - skip
        return info

    # Now that we have a master fqdn, we can get login info
    if opt.master_login:
        info['login'] = opt.master_login
    elif cfg.has_option(info['fqdn'], 'login'):
        info['login'] = cfg.get(info['fqdn'], 'login')
    else:
        return info

    # And finally pwd
    if opt.master_password:
        info['password'] = opt.master_password
    elif cfg.has_option(info['fqdn'], 'password'):
        info['password'] = cfg.get(info['fqdn'], 'password')
    else:
        return info

    return info


def getSlaveConnectionInfo(opt, cfg):
    "Make sure we have login, password, and fqdn for SLAVE, based on options and config-files"
    info = {}

    info['debug'] = opt.debug

    if 'slave' in opt.__dict__:
        info['fqdn'] = opt.slave
    elif cfg.has_option('Default', 'slave.default'):
        info['fqdn'] = cfg.get('Default', 'slave.default')
    else:  # No slave - skip
        return info

    # Now that we have a slave fqdn, we can get login info
    if opt.slave_login:
        info['login'] = opt.slave_login
    elif cfg.has_option(info['fqdn'], 'login'):
        info['login'] = cfg.get(info['fqdn'], 'login')
    else:
        return info

    # And finally pwd
    if opt.slave_password:
        info['password'] = opt.slave_password
    elif cfg.has_option(info['fqdn'], 'password'):
        info['password'] = cfg.get(info['fqdn'], 'password')
    else:
        return info

    return info


def validateConnectInfo(info):
    "Something needs to connect - make sure we have fqdn/login/pwd, and ask for "
    " anything missing"

    if not 'fqdn' in info or not info['fqdn'] or info['fqdn'] == UNKNOWN_FQDN:
        fail("Can't connect, I don't know what machine you want to go to!")
    elif "." not in info['fqdn']:
        fail("Machine domain name is not fully qualified!")
    elif not info.get('login'):
        info['login'] = ask("Admin login for " + info['fqdn'])
        if not 'login' in info:
            fail("Can't connect, I don't have a login to use!")
    
    if not 'password' in info or not info['password']:
        info['password'] = ask("Password for " + info['login'] + " on machine " + info['fqdn'], password=True)

    return info


def connectTo(info):
    logging.debug("Connect-to info = %s" % info)
    logging.info("Connecting to " + info['login'] + "@" + str(info['fqdn']))
    info = validateConnectInfo(info)
    url = "https://%(fqdn)s/rpc/api" % {"fqdn": info['fqdn']}
    cnx_dbg = 0
    if info['debug']:
        cnx_dbg = 1
    client = xmlrpclib.Server(url, verbose=cnx_dbg)
    key = client.auth.login(info['login'], info['password'])
    return {"client": client, "key": key}


def orgByName(orgs):
    org_map = {}
    for org in orgs:
        org_map[org['name']] = org['id']
    return org_map


def determineTemplateFilename(kind, fqdn, opt, cfg):
    logging.debug("detTmplFilename kind = %s, fqdn = %s, opt = %s, cfg = %s" % (kind, fqdn, opt, cfg))

    if kind == 'master':
        if opt.master_file:
            return opt.master_file
        elif cfg.has_option(fqdn, 'master.setup'):
            return cfg.get(fqdn, 'master.setup')
        else:
            return DEFAULT_MASTER_SETUP_FILENAME
    elif kind == 'slave':
        if opt.slave_file:
            return opt.slave_file
        elif cfg.has_option(fqdn, 'slave.setup') and len(cfg.get(fqdn, 'slave.setup')) != 0:
            return cfg.get(fqdn, 'slave.setup')
        else:
            return DEFAULT_SLAVE_SETUP_FILENAME
    else:
        return None


def gen_slave_template(slave_session, master_session, master, filename, dflt_master):
    "Generates a default setup applying to the specified master, for the connected-slave"
    logging.info("Generating slave-setup file " + filename)

    master_orgs = master_session['client'].org.listOrgs(master_session['key'])
    master_map = orgByName(master_orgs)
    logging.debug("MASTER ORG MAP %s" % master_map)

    slave_orgs = slave_session['client'].org.listOrgs(slave_session['key'])
    slave_map = orgByName(slave_orgs)
    logging.debug("SLAVE ORG MAP %s" % slave_map)

    slave_setup = SafeConfigParser()
    slave_setup.optionxform = str

    if path.isfile(filename) and access(filename, R_OK):
        slave_setup.readfp(open(filename))

    # Overwrite anything existing for this master - we're starting over
    if slave_setup.has_section(master):
        slave_setup.remove_section(master)

    slave_setup.add_section(master)

    if (dflt_master):
        slave_setup.set(master, 'isDefault', '1')
    else:
        slave_setup.set(master, 'isDefault', '0')

    # wget -q -O <master-ca-cert-path> http://<master-fqdn>/pub/RHN-ORG-TRUSTED-SSL-CERT

    master_ca_cert_path = '/usr/share/rhn/' + master + '_RHN-ORG-TRUSTED-SSL-CERT'
    slave_setup.set(master, 'cacert', master_ca_cert_path)

    wget_cmd = 'wget -q -O ' + master_ca_cert_path + ' http://' + master + '/pub/RHN-ORG-TRUSTED-SSL-CERT'
    logging.info("About to wget master CA cert: [" + wget_cmd + "]")
    try:
        os.system(wget_cmd)
    except Exception, e:
        logging.error("...FAILED - do you have permission to write to /usr/share/rhn?")
        logging.exception()

    for org in master_orgs:
        if org['name'] in slave_map:
            master_org = "%s|%s|%s" % (org['id'], org['name'], slave_map[org['name']])
            slave_setup.set(master, str(org['id']), str(master_org))
        else:
            master_org = "%s|%s|%s" % (org['id'], org['name'], 1)
            slave_setup.set(master, str(org['id']), master_org)

    try:
        configfile = open(filename, 'w+')
        slave_setup.write(configfile)
        configfile.close()
    except IOError, e:
        logging.error("FAILED to write to slave template [" + filename + "]")
        sys.exit(1)

    return


def gen_master_template(master_session, slave, filename):
    "Generates a default setup applying to the specified slave, for the connected-master"
    logging.info("Generating master-setup file " + filename)

    master_setup = SafeConfigParser()
    master_setup.optionxform = str

    if path.isfile(filename) and access(filename, R_OK):
        master_setup.readfp(open(filename, 'r'))

    # Overwrite anything we have for this slave - we're starting over
    if master_setup.has_section(slave):
        master_setup.remove_section(slave)

    master_setup.add_section(slave)

    if not master_setup.has_option(slave, "isEnabled"):
        master_setup.set(slave, 'isEnabled', str(1))

    if not master_setup.has_option(slave, "allowAllOrgs"):
        master_setup.set(slave, 'allowAllOrgs', str(1))

    if not master_setup.has_option(slave, "allowedOrgs"):
        idlist = []
        for org in master_session['client'].org.listOrgs(master_session['key']):
            idlist.append(org['id'])
        logging.debug("idlist %s" % idlist)
        master_setup.set(slave, 'allowedOrgs', ",".join(str(i) for i in idlist))

    try:
        mfile = open(filename, 'w+')
        master_setup.write(mfile)
        mfile.close()
    except IOError, e:
        logging.error("FAILED to write to master template [" + mfile + "]")
        sys.exit(1)

    return


def apply_slave_template(slave_session, slave_setup_filename):
    "Updates the connected slave with information for the master(s) contained in the specified slave-setup-file"
    logging.info("Applying slave-setup " + slave_setup_filename)
    client = slave_session['client']
    key = slave_session['key']

    slave_setup = SafeConfigParser()
    if path.isfile(slave_setup_filename) and access(slave_setup_filename, R_OK):
        slave_setup.readfp(open(filename))
    else:
        fail("Can't find slave-setup file [" + slave_setup_filename + "]")

    fqdns = slave_setup.sections()

    for fqdn in fqdns:
        try:
            master = client.sync.master.getMasterByLabel(key, fqdn)
        except Exception, e:
            master = client.sync.master.create(key, fqdn)

        master_orgs = []
        moids = slave_setup.options(fqdn)
        # key is either master-org-id, or one of (isDefault, cacert) - skip those
        for moid in moids:  # moid|moname|local_oid
            if lower(moid) == "isdefault" or lower(moid) == "cacert":
                continue
            minfo = slave_setup.get(fqdn, moid)
            elts = string.split(minfo, '|')
            orginfo = {}
            orginfo['masterOrgId'] = int(elts[0])
            orginfo['masterOrgName'] = elts[1]
            if len(elts[2]) > 0:
                orginfo['localOrgId'] = int(elts[2])
            master_orgs.append(orginfo)

        client.sync.master.setMasterOrgs(key, master['id'], master_orgs)

        if slave_setup.has_option(fqdn, 'isDefault') and slave_setup.get(fqdn, 'isDefault') == '1':
            client.sync.master.makeDefault(key, master['id'])

        if slave_setup.has_option(fqdn, 'caCert'):
            client.sync.master.setCaCert(key, master['id'], slave_setup.get(fqdn, 'caCert'))

    return


def describe_slave_template(slave_setup_filename):
    "Tells us what it _would have_ done to a connected slave with information for "
    "the master(s) contained in the specified slave-setup-file"

    logging.info("Applying contents of file [" + slave_setup_filename + "] to SLAVE")

    slave_setup = SafeConfigParser()
    if path.isfile(slave_setup_filename) and access(slave_setup_filename, R_OK):
        slave_setup.readfp(open(filename))
    else:
        fail("Can't find slave-setup file [" + slave_setup_filename + "]")

    fqdns = slave_setup.sections()

    for fqdn in fqdns:
        logging.info("Updating info for master [" + fqdn + "]")
        if slave_setup.has_option(fqdn, 'isDefault') and slave_setup.get(fqdn, 'isDefault') == '1':
            logging.info("  Setting this master to the default-master for this slave")

        if slave_setup.has_option(fqdn, 'caCert'):
            logging.info("  Setting the path to this master's CA-CERT to [" + slave_setup.get(fqdn, 'caCert') + "]")

        moids = slave_setup.options(fqdn)
        for moid in moids:  # moid|moname|local_oid
            if lower(moid) == "isdefault" or lower(moid) == "cacert":
                continue
            minfo = slave_setup.get(fqdn, moid)
            elts = string.split(minfo, '|')
            logging.info("  Mapping master OrgId [%s], named [%s], to local OrgId [%s]" %
                         (elts[0], elts[1], elts[2]))
        logging.info("")

    return


def apply_master_template(master_session, master_setup_filename):
    "Updates the connected master with information for the slave(s) contained in the specified master-setup-file"
    logging.info("Applying master-setup " + master_setup_filename)
    client = master_session['client']
    key = master_session['key']

    master_setup = SafeConfigParser()
    if path.isfile(master_setup_filename) and access(master_setup_filename, R_OK):
        master_setup.readfp(open(filename))
    else:
        fail("Can't find master-setup file [" + master_setup_filename + "]")

    fqdns = master_setup.sections()

    for fqdn in fqdns:
        try:
            slave = client.sync.slave.getSlaveByName(key, fqdn)
        except Exception, e:
            slave = client.sync.slave.create(key, fqdn, True, True)

        isEnabled = True
        allowAll = True

        if master_setup.has_option(fqdn, 'isEnabled'):
            isEnabled = master_setup.getboolean(fqdn, 'isEnabled')

        if master_setup.has_option(fqdn, 'allowAllOrgs'):
            allowAll = master_setup.getboolean(fqdn, 'allowAllOrgs')

        client.sync.slave.update(key, slave['id'], fqdn, isEnabled, allowAll)

        master_orgs = []
        if master_setup.has_option(fqdn, 'allowedOrgs'):
            master_orgs = [int(x) for x in master_setup.get(fqdn, 'allowedOrgs').split(',')]

        client.sync.slave.setAllowedOrgs(key, slave['id'], master_orgs)
    return


def describe_master_template(master_setup_filename):
    "Tells us what it _would have_ done to a connected master with information for "
    "the slave(s) contained in the specified master-setup-file"

    logging.info("Applying contents of file [" + master_setup_filename + "] to MASTER")

    master_setup = SafeConfigParser()
    if path.isfile(master_setup_filename) and access(master_setup_filename, R_OK):
        master_setup.readfp(open(filename))
    else:
        fail("Can't find master-setup file [" + master_setup_filename + "]")

    fqdns = master_setup.sections()

    for fqdn in fqdns:
        logging.info("Updating info for slave [" + fqdn + "]")

        if master_setup.has_option(fqdn, 'isEnabled'):
            logging.info("  isEnabled = %s" % master_setup.getboolean(fqdn, 'isEnabled'))
        else:
            logging.info("  isEnabled = 1")

        if master_setup.has_option(fqdn, 'allowAllOrgs'):
            logging.info("  allowAllOrgs = %s" % master_setup.getboolean(fqdn, 'allowAllOrgs'))
        else:
            logging.info("  allowAllOrgs = 1")

        master_orgs = []
        if master_setup.has_option(fqdn, 'allowedOrgs'):
            master_orgs = [int(x) for x in master_setup.get(fqdn, 'allowedOrgs').split(',')]
        logging.info("  allowedOrgs = %s" % master_orgs)

        logging.info("")

    return

def ask(msg, password=False):
    msg += ": "
    return password and getpass.getpass(msg) or raw_input(msg)


def fail(msg):
    logging.error(msg)
    logging.info("See spacewalk-sync-setup --help")
    sys.exit()


if __name__ == '__main__':
    if len(sys.argv) == 1 and os.path.exists(USER_CONF_FILE):
        sys.argv.append("-h")

    parser = setupOptions()
    (options, args) = parser.parse_args()
    setupLogging(options)
    logging.debug("OPTIONS = %s" % options)

    config = setupConfig(options)
    logging.debug("CONFIG = %s" % config)

    if (options.apply or options.describe_templates) and \
            (not options.configured_hosts and \
                 not options.master and \
                 not options.slave):
        logging.info("You should pass \"--configured-hosts\" option or specify master and/or slave hosts!")
        sys.exit(1)

    master_info = getMasterConnectionInfo(options, config)
    if options.master_template or options.slave_template or options.both_template or options.apply:
        master_cnx = connectTo(master_info)
        logging.debug("Master cnx = %s" % master_cnx)

    slave_info = getSlaveConnectionInfo(options, config)
    if options.master_template or options.slave_template or options.both_template or options.apply:
        slave_cnx = connectTo(slave_info)
        logging.debug("Slave cnx = %s" % slave_cnx)

    if options.master_template or options.both_template:
        filename = determineTemplateFilename('master', slave_info['fqdn'], options, config)
        gen_master_template(master_cnx, slave_info['fqdn'], filename)

    if options.slave_template or options.both_template:
        filename = determineTemplateFilename('slave', master_info['fqdn'], options, config)
        gen_slave_template(slave_cnx, master_cnx, master_info['fqdn'], filename, options.master_default)

    if (options.master or options.configured_hosts):
        filename = determineTemplateFilename('master', slave_info['fqdn'], options, config)
        if options.apply:
            apply_master_template(master_cnx, filename)
        elif options.describe_templates:
            describe_master_template(filename)

    if (options.slave or options.configured_hosts):
        filename = determineTemplateFilename('slave', master_info['fqdn'], options, config)
        if options.apply:
            apply_slave_template(slave_cnx, filename)
        elif options.describe_templates:
            describe_slave_template(filename)
