#!/usr/bin/python
#
# Utility for setting up ISS master/slave org-mappings
#
# Copyright (c) 2013 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, os, re, sys, xmlrpclib, string
from string import lower
from ConfigParser import SafeConfigParser, NoOptionError
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')

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',
        help="name of a slave to connect to.")
    cnxGrp.add_option('--sl', '--slave-login', action='store', dest='slave_login', default="",
        help="A sat-admin login for slave-server")
    cnxGrp.add_option('--sp', '--slave-password', action='store', dest='slave_password', default="",
        help="Password for login slave-login on slave-server")
    cnxGrp.add_option('--ms', '--master-server', action='store', dest='master',
        help="name of a master to connect to.")
    cnxGrp.add_option('--ml', '--master-login', action='store', dest='master_login', default="",
        help="A sat-admin login for master-server")
    cnxGrp.add_option('--mp', '--master-password', action='store', dest='master_password', default="",
        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 = {}

    if opt.master:
        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 = {}

    if opt.slave:
        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']:
        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 info.get('login'):
        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)
    info = validateConnectInfo(info)
    logging.info("Connecting to " + info['login'] + "@" + info['fqdn'])
    url = "https://%(fqdn)s/rpc/api" % {"fqdn" : info['fqdn']}
    client = xmlrpclib.Server(url, verbose=0)
    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)
    sys.exit(1)


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)
