#!/usr/bin/env python
'''bcfg2-admin is a script that helps to administrate a bcfg2 deployment'''

import getopt, difflib, logging, lxml.etree, os, popen2, re, socket, sys, ConfigParser
import Bcfg2.Server.Core, Bcfg2.Logging, Bcfg2.tlslite.api
import binascii, time

log = logging.getLogger('bcfg-admin')

colors = ['steelblue1', 'chartreuse', 'gold', 'magenta', 'indianred1', 'limegreen', 
          'orange1', 'lightblue2', 'green1', 'blue1', 'yellow1', 'darkturquoise',
          'gray66']

usage = '''
bcfg2-admin [options]
fingerprint - print the server certificate fingerprint
init        - initialize the bcfg2 repository
              (this is interactive; only run once)
pull        <client> <entry type> <entry name> 
            - mine statistics for entry information
minestruct  <client> 
            - mine statistics for extra entries
viz         [--includehosts] [--includebundles] [--includekey] 
            [-o output.png] [--raw]
client      add name= profile= uuid= password= address= secure= location=
tidy        - clean up unused files from repo
compare     <config1.xml> <config2.xml>
            - compare two configurations for differences
'''

config = '''
[server]
repository = %s
structures = Bundler,Base
generators = SSHbase,Cfg,Pkgmgr,Rules

[statistics]
sendmailpath = /usr/sbin/sendmail
database_engine = sqlite3
# 'postgresql', 'mysql', 'mysql_old', 'sqlite3' or 'ado_mssql'.
database_name =
# Or path to database file if using sqlite3.
#<repository>/etc/brpt.sqlite is default path if left empty
database_user =
# Not used with sqlite3.
database_password =
# Not used with sqlite3.
database_host =
# Not used with sqlite3.
database_port =
# Set to empty string for default. Not used with sqlite3.
web_debug = True


[communication]
protocol = xmlrpc/ssl
password = %s
key = %s/bcfg2.key

[components]
bcfg2 = %s
'''

groups = '''
<Groups version='3.0'>
   <Group profile='true' public='false' default='true' name='basic'>
      <Group name='%s'/>
   </Group>
   <Group name='ubuntu'   toolset='debian'/>
   <Group name='debian'   toolset='debian'/>
   <Group name='freebsd'  toolset='freebsd'/>
   <Group name='gentoo'   toolset='gentoo'/>
   <Group name='redhat'   toolset='rh'/>
   <Group name='suse'     toolset='rh'/>
   <Group name='mandrake' toolset='rh'/>
   <Group name='solaris'  toolset='solaris'/>
</Groups>
'''
clients = '''
<Clients version="3.0">
   <Client profile="basic" pingable="Y" pingtime="0" name="%s"/>
</Clients>
'''

prompt = '''
please select which operating system your machine is running:
a. Redhat/Fedora/RHEL/RHAS/Centos
b. SUSE/SLES
c. Mandrake
d. Debian
e. Ubuntu
f. Solaris
g. Gentoo
h. FreeBSD
'''

def err_exit(emsg):
    print emsg
    raise SystemExit(1)

#build bcfg2.conf file
def initialize_repo(cfile):
    '''Setup a new repo'''
    repo = raw_input( "location of bcfg2 repository [/var/lib/bcfg2]: " )
    if repo == '':
        repo = '/var/lib/bcfg2'

    password = ''
    while ( password == '' ):
        password = raw_input( "please provide the password used for communication verification: " )

    #get the hostname
    server = "https://%s:6789" % socket.getfqdn()
    uri = raw_input( "please provide the server location[%s]: " % server)
    if uri == '':
        uri = server

    #guess path of ssl key file
    keypath = os.path.dirname(os.path.abspath(cfile))

    open(cfile,"w").write(config % ( repo, password, keypath, uri ))

    #generate the ssl key
    print "Now we will generate the ssl key used for secure communitcation"
    os.popen('openssl req -x509 -nodes -days 1000 -newkey rsa:1024 -out %s/bcfg2.key -keyout %s/bcfg2.key' % (keypath, keypath))
    try:
        os.chmod('%s/bcfg2.key'% keypath,'0600')
    except:
        pass
    
    #create the repo dirs
    for subdir in ['SSHbase', 'Cfg', 'Pkgmgr', 'Rules', 'etc', 'Metadata',
                   'Base', 'Bundler']:
        path = "%s/%s" % (repo, subdir)
        newpath = ''
        for subdir in path.split('/'):
            newpath = newpath + subdir + '/'
            try:
                os.mkdir(newpath)
            except:
                pass
            
    #create the groups.xml file
    selection = ''
    while ( selection == '' ):
        print prompt
        selection = raw_input(" selection: ")
        if selection.lower() not in 'abcdefgh':
            selection = ''
    if selection.lower() == 'a':
        selection = 'redhat'
    elif selection.lower() == 'b':
        selection = 'suse'
    elif selection.lower() == 'c':
        selection = 'mandrake'
    elif selection.lower() == 'd':
        selection = 'debian'
    elif selection.lower() == 'e':
        selection = 'ubuntu'
    elif selection.lower() == 'f':
        selection = 'solaris'
    elif selection.lower() == 'g':
        selection = 'gentoo'
    elif selection.lower() == 'h':
        selection = 'freebsd'

    open("%s/Metadata/groups.xml"%repo, "w").write(groups%selection)

    #now the clients file
    open("%s/Metadata/clients.xml"%repo, "w").write(clients%socket.getfqdn())
    print "Repository created successfuly in %s" % (repo)

def get_repo_path(cfile='/etc/bcfg2.conf'):
    '''return repository path'''
    cfp = ConfigParser.ConfigParser()
    cfp.read(cfile)
    return cfp.get('server', 'repository')

def load_stats(repo, client):
    stats = lxml.etree.parse("%s/etc/statistics.xml" % (repo))
    hostent = stats.xpath('//Node[@name="%s"]' % client)
    if not hostent:
        err_exit("Could not find stats for client %s" % (client))
    return hostent[0]

important = {'Package':['name', 'version'],
             'Service':['name', 'status'],
             'Directory':['name', 'owner', 'group', 'perms'],
             'SymLink':['name', 'to'],
             'ConfigFile':['name', 'owner', 'group', 'perms'],
             'Permissions':['name', 'perms'],
             'PostInstall':['name']}

def compare(new, old):
    for child in new.getchildren():
        equiv = old.xpath('%s[@name="%s"]' % (child.tag, child.get('name')))
        if not important.has_key(child.tag):
            print "tag type %s not handled" % (child.tag)
            continue
        if len(equiv) == 0:
            print "didn't find matching %s %s" % (child.tag, child.get('name'))
            continue
        elif len(equiv) >= 1:
            if child.tag == 'ConfigFile':
                if child.text != equiv[0].text:
                    print " %s %s contents differ" \
                          % (child.tag, child.get('name'))
                    continue
            noattrmatch = [field for field in important[child.tag] if \
                           child.get(field) != equiv[0].get(field)]
            if not noattrmatch:
                new.remove(child)
                old.remove(equiv[0])
            else:
                print " %s %s attributes %s do not match" % \
                      (child.tag, child.get('name'), noattrmatch)
    if len(old.getchildren()) == 0 and len(new.getchildren()) == 0:
        return True
    if new.tag == 'Independant':
        name = 'Base'
    else:
        name = new.get('name')
    both = []
    oldl = ["%s %s" % (entry.tag, entry.get('name')) for entry in old]
    newl = ["%s %s" % (entry.tag, entry.get('name')) for entry in new]
    for entry in newl:
        if entry in oldl:
            both.append(entry)
            newl.remove(entry)
            oldl.remove(entry)
    for entry in both:
        print " %s differs (in bundle %s)" % (entry, name)
    for entry in oldl:
        print " %s only in old configuration (in bundle %s)" % (entry, name)
    for entry in newl:
        print " %s only in new configuration (in bundle %s)" % (entry, name)
    return False

def do_compare(cargs):
    '''run file comparison'''
    if '-r' in cargs:
        cargs.remove('-r')
        (oldd, newd) = args
        (old, new) = [os.listdir(spot) for spot in args]
        for item in old:
            print "Entry:", item
            state = do_compare([oldd + '/' + item, newd + '/' + item])
            new.remove(item)
            if state:
                print "Entry:", item, "good"
            else:
                print "Entry:", item, "bad"
        if new:
            print "new has extra entries", new
        return
    try:
        (old, new) = cargs
    except IndexError:
        print "Usage: bcfg2-admin compare <old> <new>"
        raise SystemExit

    try:
        new = lxml.etree.parse(new).getroot()
    except IOError:
        print "Failed to read %s" % (new)
        raise SystemExit(1)

    try:
        old = lxml.etree.parse(old).getroot()
    except IOError:
        print "Failed to read %s" % (old)
        raise SystemExit(1)
    
    for src in [new, old]:
        for bundle in src.findall('./Bundle'):
            if bundle.get('name')[-4:] == '.xml':
                bundle.set('name', bundle.get('name')[:-4])

    rcs = []
    for bundle in new.findall('./Bundle'):
        equiv = old.xpath('Bundle[@name="%s"]' % (bundle.get('name')))
        if len(equiv) == 0:
            print "couldnt find matching bundle for %s" % bundle.get('name')
            continue
        if len(equiv) == 1:
            if compare(bundle, equiv[0]):
                new.remove(bundle)
                old.remove(equiv[0])
                rcs.append(True)
            else:
                rcs.append(False)
        else:
            print "dunno what is going on for bundle %s" % (bundle.get('name'))
    i1 = new.find('./Independant')
    i2 = old.find('./Independant')
    if compare(i1, i2):
        new.remove(i1)
        old.remove(i2)
    else:
        rcs.append(False)
    return not False in rcs

def do_fingerprint(cfile):
    '''calculate key fingerprint'''
    cf = ConfigParser.ConfigParser()
    cf.read([cfile])
    keypath = cf.get('communication', 'key')
    x509 = Bcfg2.tlslite.api.X509()
    x509.parse(open(keypath).read())
    print x509.getFingerprint()

def do_pull(cfile, repopath, client, etype, ename):
    '''Make currently recorded client state correct for entry'''
    sdata = load_stats(repopath, client)
    if sdata.xpath('.//Statistics[@state="dirty"]'):
        state = 'dirty'
    else:
        state = 'clean'
    # need to pull entry out of statistics
    sxpath = ".//Statistics[@state='%s']/Bad/ConfigFile[@name='%s']/../.." % (state, ename)
    sentries = sdata.xpath(sxpath)
    print "Found %d entries for %s:%s:%s" % \
          (len(sentries), client, etype, ename)
    if not len(sentries):
        raise SystemExit, 1
    maxtime = max([time.strptime(stat.get('time')) for stat in sentries])
    print "Found entry from", time.strftime("%c", maxtime)
    statblock = [stat for stat in sentries \
                 if time.strptime(stat.get('time')) == maxtime]
    entry = statblock[0].xpath('.//Bad/ConfigFile[@name="%s"]' % ename)
    if not entry:
        err_exit("Could not find state data for entry; rerun bcfg2 on client system")
    cfentry = entry[-1]


    badfields = [field for field in ['perms', 'owner', 'group'] \
                 if cfentry.get(field) != cfentry.get('current_' + field) and \
                 cfentry.get('current_' + field)]
    if badfields:
        m_updates = dict([(field, cfentry.get('current_' + field)) for field in badfields])
        print "got metadata_updates", m_updates
    else:
        m_updates = {}

    if 'current_bdiff' in cfentry.attrib:
        data = False
        diff = binascii.a2b_base64(cfentry.get('current_bdiff'))
    elif 'current_diff' in cfentry.attrib:
        data = False
        diff = cfentry.get('current_diff')
    elif 'current_bfile' in cfentry.attrib:
        data = binascii.a2b_base64(cfentry.get('current_bfile'))
        diff = False
    else:
        if not m_updates:
            print "having trouble processing entry. Entry is:"
            print lxml.etree.tostring(cfentry)
            raise SystemExit, 1
        else:
            data = False
            diff = False

    if diff:
        print "Located diff:\n %s" % diff
    elif data:
        print "Found full (binary) file data"
    if m_updates:
        print "Found metadata updates"

    if not diff and not data and not m_updates:
        err_exit("Failed to locate diff or full data or metadata updates\nStatistics entry was:\n%s" % lxml.etree.tostring(cfentry))

    try:
        bcore = Bcfg2.Server.Core.Core({}, cfile)
    except Bcfg2.Server.Core.CoreInitError, msg:
        print "Core load failed because %s" % msg
        raise SystemExit(1)
    [bcore.fam.Service() for x in range(10)]
    while bcore.fam.Service():
        pass
    m = bcore.metadata.get_metadata(client)
    # find appropriate plugin in bcore
    glist = [gen for gen in bcore.generators if
             gen.Entries.get(etype, {}).has_key(ename)]
    if len(glist) != 1:
        err_exit("Got wrong numbers of matching generators for entry:" \
                 + "%s" % ([g.__name__ for g in glist]))
    plugin = glist[0]
    try:
        plugin.AcceptEntry(m, 'ConfigFile', ename, diff, data, m_updates)
    except Bcfg2.Server.Plugin.PluginExecutionError:
        err_exit("Configuration upload not supported by plugin %s" \
                 % (plugin.__name__))
    # svn commit if running under svn

def do_minestruct(repopath, argdata):
    '''Pull client entries into structure'''
    if len(argdata) != 1:
        err_exit("minestruct must be called with a client name")
    client = argdata[0]
    stats = load_stats(repopath, client)
    if len(stats.getchildren()) == 2:
        # client is dirty
        current = [ent for ent in stats.getchildren() if ent.get('state') == 'dirty'][0]
    else:
        current = stats.getchildren()[0]
    extra = current.find('Extra').getchildren()
    log.info("Found %d extra entries" % (len(extra)))
    log.info(["%s: %s" % (entry.tag, entry.get('name')) for entry in extra])

def do_tidy(repo, args):
    '''Clean up unused or unusable files from the repository'''
    hostmatcher = re.compile('.*\.H_(\S+)$')
    score = ([], [])
    # clean up unresolvable hosts in SSHbase
    for name in os.listdir("%s/SSHbase" % (repo)):
        if not hostmatcher.match(name):
            print "could not match name %s" % (name)
        else:
            hostname = hostmatcher.match(name).group(1)
            if hostname in score[0] + score[1]:
                continue
            try:
                socket.gethostbyname(hostname)
                score[0].append(hostname)
            except:
                print "could not resolve %s" % (hostname)
                score[1].append(hostname)
    for name in os.listdir("%s/SSHbase" % (repo)):
        if not hostmatcher.match(name):
            print "could not match name %s" % (name)
        else:
            if hostmatcher.match(name).group(1) in score[1]:
                if '-f' in args:
                    os.unlink("%s/SSHbase/%s" % (repo, name))
                else:
                    answer = raw_input("Unlink file %s? [yN] " % name)
                    if answer in ['y', 'Y']:
                        os.unlink("%s/SSHbase/%s" % (repo, name))
    # clean up file~
    # clean up files without parsable names in Cfg

def do_viz(repopath, myargs):
    '''Build visualization of groups file'''
    # First get options to the 'viz' subcommand
    try:
        opts, args = getopt.getopt(myargs, 'rhbko:', ['raw', 'includehosts', 'includebundles', 'includekey', 'outfile='])
    except getopt.GetoptError, msg:
        print msg
        raise SystemExit(1)

    options = []
    for opt, arg in opts:
        if   opt in ("-r", "--raw"):
            options.append("raw")
        elif opt in ("-h", "--includehosts"):
            options.append("hosts")
        elif opt in ("-b", "--includebundles"):
            options.append("bundles")
        elif opt in ("-k", "--includekey"):
            options.append("key")
        elif opt in ("-o", "--outfile"):
            options.append("outfile")
            outputfile = arg

    groupdata = lxml.etree.parse(repopath + '/Metadata/groups.xml')
    groupdata.xinclude()
    groups = groupdata.getroot()
    if 'raw' in options:
        dotpipe = popen2.Popen4("dd bs=4M 2>/dev/null")
    else:
        dotpipe = popen2.Popen4("dot -Tpng")
    categories = {'default':'grey83'}
    instances = {}
    egroups = groups.findall("Group") + groups.findall('.//Groups/Group')
    for group in egroups:
        if not categories.has_key(group.get('category')):
            categories[group.get('category')] = colors.pop()
        group.set('color', categories[group.get('category')])
    if None in categories:
        del categories[None]
        
    try:
        dotpipe.tochild.write("digraph groups {\n")
    except:
        print "write to dot process failed. Is graphviz installed?"
        raise SystemExit(1)
    dotpipe.tochild.write('\trankdir="LR";\n')
    if 'hosts' in options:
        clients = lxml.etree.parse(repopath + '/Metadata/clients.xml').getroot()
        for client in clients.findall('Client'):
            if instances.has_key(client.get('profile')):
                instances[client.get('profile')].append(client.get('name'))
            else:
                instances[client.get('profile')] = [client.get('name')]
        for profile, clist in instances.iteritems():
            clist.sort()
            dotpipe.tochild.write('''\t"%s-instances" [ label="%s", shape="record" ];\n''' % (profile, '|'.join(clist)))
            dotpipe.tochild.write('''\t"%s-instances" -> "group-%s";\n''' % (profile, profile))

    if 'bundles' in options:
        bundles = []
        [bundles.append(bund.get('name')) for bund in groups.findall('.//Bundle')
         if bund.get('name') not in bundles]
        bundles.sort()
        for bundle in bundles:
            dotpipe.tochild.write('''\t"bundle-%s" [ label="%s", shape="septagon"];\n''' % (bundle, bundle))
    gseen = []
    for group in egroups:
        if group.get('profile', 'false') == 'true':
            style = "filled, bold"
        else:
            style = "filled"
        gseen.append(group.get('name'))
        dotpipe.tochild.write('\t"group-%s" [label="%s", style="%s", fillcolor=%s];\n' %
                              (group.get('name'), group.get('name'), style, group.get('color')))
        if 'bundles' in options:
            for bundle in group.findall('Bundle'):
                dotpipe.tochild.write('\t"group-%s" -> "bundle-%s";\n' %
                                      (group.get('name'), bundle.get('name')))
        
    for group in egroups:
        for parent in group.findall('Group'):
            if parent.get('name') not in gseen:
                dotpipe.tochild.write('\t"group-%s" [label="%s", style="filled", fillcolor="grey83"];\n' %
                                      (parent.get('name'), parent.get('name')))
                gseen.append(parent.get("name"))
            dotpipe.tochild.write('\t"group-%s" -> "group-%s" ;\n' %
                                  (group.get('name'), parent.get('name')))
    if 'key' in options:
        dotpipe.tochild.write("\tsubgraph cluster_key {\n")
        dotpipe.tochild.write('''\tstyle="filled";\n''')
        dotpipe.tochild.write('''\tcolor="lightblue";\n''')
        dotpipe.tochild.write('''\tBundle [ shape="septagon" ];\n''')
        dotpipe.tochild.write('''\tGroup [shape="ellipse"];\n''')
        dotpipe.tochild.write('''\tProfile [style="bold", shape="ellipse"];\n''')
        dotpipe.tochild.write('''\tHblock [label="Host1|Host2|Host3", shape="record"];\n''')
        for category in categories:
            dotpipe.tochild.write('''\t"''' + category + '''" [label="''' + category + \
                                  '''", shape="record", style="filled", fillcolor=''' + \
                                  categories[category] + '''];\n''')
        dotpipe.tochild.write('''\tlabel="Key";\n''')
        dotpipe.tochild.write("\t}\n")
    dotpipe.tochild.write("}\n")
    dotpipe.tochild.close()
    data = dotpipe.fromchild.read()
    if 'outfile' in options:
        output = open(outputfile, 'w').write(data)
    else:
        print data

def do_client(repopath, args):
    '''Do things with clients'''
    tree = lxml.etree.parse(repopath + '/Metadata/clients.xml')
    root = tree.getroot()

    if args[0] == 'add':
        # Adding a node
        print "Adding client..."
        element = lxml.etree.Element("Client")
        for i in args[1:]:
            attr, val = i.split('=', 1)
            if not(attr in ['name', 'profile', 'uuid', 'password', 'address', 'secure', 'location']):
                print "Attribute %s unknown" % attr
                raise SystemExit(1)
            element.attrib[attr] = val
        root.append(element)

    elif args[0] in ['delete', 'remove']:
        # Removing a node
        print "Removing"

    tree.write(repopath + '/Metadata/clients.xml')
    print "Done"
    
if __name__ == '__main__':
    Bcfg2.Logging.setup_logging('bcfg2-admin', to_console=False)

    # Some sensible defaults
    configfile = "/etc/bcfg2.conf"
    Repopath = ""

    try:
        opts, args = getopt.getopt(sys.argv[1:], 'hC:R:', ['help', 'configfile=', 'repopath='])
    except getopt.GetoptError, msg:
        print msg
        raise SystemExit(1)

    if not args:
        print usage
        raise SystemExit(1)

    # First get the options...
    for opt, arg in opts:
        if opt in ("-h", "--help"):
            print usage
            raise SystemExit(1)
        if opt in ("-C", "--configfile"):
            configfile = arg
        if opt in ("-R", "--repopath"):
            Repopath = arg

    # ...then do something with the other arguments
    if Repopath == '' and 'init' not in args:
        Repopath = get_repo_path(configfile)
            
    if len(args) < 1:
        print usage

    elif args[0] == "init":
        initialize_repo(configfile)

    elif args[0] == 'pull':
        if len(args) != 4:
            print usage
            raise SystemExit(1)
        do_pull(configfile, Repopath, args[1], args[2], args[3])

    elif args[0] == 'minestruct':
        do_minestruct(Repopath, args[1:])

    elif args[0] == 'tidy':
        do_tidy(Repopath, args[1:])

    elif args[0] == 'viz':
        do_viz(Repopath, args[1:])

    elif args[0] == 'compare':
        do_compare(args[1:])

    elif args[0] == 'fingerprint':
        do_fingerprint(configfile)

    elif args[0] == 'client':
        if len(args) < 4:
            print usage
            raise SystemExit(1)
        do_client(Repopath, args[1:])
    else:
        print usage
