#!/usr/bin/env python3
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
# Copyright (c) UPMC 2011-2015, All Rights Reserved


import sys
import re
import os
import os.path
import copy
import platform
import time
import socket
import subprocess
import logging
import optparse


standardBinPathes = [ "/usr/bin", "/usr/sbin", "/bin", "/sbin" ]


def escapeMeta ( script ):
   return script.replace("\"",r"\"").replace("$" ,r"\$")


class ErrorMessage ( Exception ):

    def __init__ ( self, message ):
        self._errors = message
        return

    def __str__ ( self ):
        return self._errors


class RsyncTreeConf ( object ):

    def __init__ ( self, name, treeDir, rmode="-auhHv", excludes=None ):
        self._name       = name
        self._treeDir    = treeDir
        self._rmode      = [ rmode ]
        self._excludes   = excludes
        return

    def _getName       ( self ): return self._name
    def _getTreeDir    ( self ): return self._treeDir
    def _getRmode      ( self ): return self._rmode
    def _getExcludes   ( self ):
        excludes = []
        for pattern in self._excludes:
            excludes += [ "--exclude=" + pattern ]
        return excludes

   # Attributes access.
    name       = property(_getName)
    treeDir    = property(_getTreeDir)
    rmode      = property(_getRmode)
    excludes   = property(_getExcludes)


class HostProfile ( object ):

    MultiArch = 0x01
    Local     = 0x02
    Remote    = 0x04


    def __init__ ( self, host, fqdn, flags, sshArgs=None, postScript=None ):
        self._host       = host
        self._fqdn       = fqdn
        self._flags      = flags
        self._sshArgs    = sshArgs
        self._postScript = postScript
        self._treeConfs  = {}
        return

    def _getHost       ( self ): return self._host
    def _getFqdn       ( self ): return self._fqdn
    def _getFlags      ( self ): return self._flags
    def _getPostScript ( self ): return self._postScript
    def _getSshArgs    ( self ):
        if self._sshArgs: return ["-e", "ssh %s" % self._sshArgs]
        return []
    def _setPostScript ( self, script ): self._postScript = script

   # Attributes access.
    host       = property(_getHost)
    fqdn       = property(_getFqdn)
    flags      = property(_getFlags)
    sshArgs    = property(_getSshArgs)
    postScript = property(_getPostScript,_setPostScript)

    def addTreeConf ( self, *args ):
        if len(args) == 1:
            (treeConf,) = args
            if not isinstance(treeConf,RsyncTreeConf):
                error = "[ERROR] HostProfile.addTreeConf(): argument is *not* of type <RsyncTreeConf>"
                raise ErrorMessage(error)
            if treeConf.name in self._treeConfs:
                print( "[WARNING] Duplicated treeConf <%s> has been overwritten." % treeConf.name )
            self._treeConfs[treeConf.name] = treeConf
        elif len(args) == 4:
            (name,treeDir,rmode,excludes) = args
            self.addTreeConf(RsyncTreeConf(name,treeDir,rmode,excludes))
        return

    def getTreeConf ( self, name ):
        if name in self._treeConfs:
            return self._treeConfs[name]
        return None

    def getTreeDir ( self, name, mode ):
        if name in self._treeConfs:
            treeConf = self._treeConfs[name]
            if   mode & self.Local:  return treeConf.treeDir+"/"
            elif mode & self.Remote: return self._fqdn + ":" + treeConf.treeDir+"/"
        return None

    def getExcludes ( self, name ):
        if name in self._treeConfs:
            return self._treeConfs[name].excludes
        return []

    def getRmode ( self, name ):
        if name in self._treeConfs:
            return self._treeConfs[name].rmode
        return ""


class HostProfiles:

    def __init__ ( self ):
        self._profiles = {}
        return

    def add ( self, host, fqdn, flags, sshArgs=None ):
        profile = HostProfile(host,fqdn,flags,sshArgs)
        self._profiles [ host ] = profile
        return profile

    def get ( self, host ):
        return self._profiles[host]


class Rsync:

    DryRun   = 0x080
    Merge    = 0x100
    Reverse  = 0x200

    def _is32bits ( arch ):
        if arch == "i386": return True
        if arch == "i486": return True
        if arch == "i586": return True
        if arch == "i686": return True
        return False

    def _is64bits ( arch ):
        if arch == "x86_64": return True
        return False

    def _printTable ( self, table, indent, header ):
        for item in range(0,len(table)):
            if item < header:
                if item == 0:
                    print( "%s%s" % (' '*indent,table[0]), end=' ' )
                    indent += len(table[0]) + 1
                else:
                    print( table[item], end='' )
                continue
            else:
                print( "\n%s%s" % (' '*indent,table[item]), end='' )
        print()
        return

    def _execute ( self, command, error ):
        sys.stdout.flush()
        sys.stderr.flush()
        child = subprocess.Popen(command, stdout=None)
        (pid,status) = os.waitpid(child.pid, 0)
        status >>= 8
        if status != 0:
            print( "[ERROR] %s (status:%d)." % (error,status) )
            sys.exit(status)
        return

    def __init__ ( self ):
        self._hostProfiles = HostProfiles ()
        self._sourceHost   = platform.node().split(".")[0]
        self._targetHost   = None
        self._logDir       = os.path.join(os.getcwd(), "log")
        self._log          = os.path.join(self._logDir, "RSYNC.log")

        if not os.path.isdir(self._logDir): os.mkdir(self._logDir)

        logging.basicConfig ( level   =logging.INFO
                            , format  ='%(asctime)s:%(levelname)-8s:%(message)s'
                            , datefmt ='%Y.%m.%d %H:%M:%S'
                            , filename=self._log
                            )

        machineArch   = platform.machine().split(".")[0]
        directoryArch = os.path.abspath(sys.argv[0]).split("/")[-2]

        return

    def add ( self, host, fqdn, flags=0, sshArgs=None ):
        profile = self._hostProfiles.add(host,fqdn,flags,sshArgs)
        return profile

    def get ( self, host ):
        return self._hostProfiles.get(host)

    def run ( self, target, flags, labels ):
        self._targetHost = target

        sourceMode = HostProfile.Local
        targetMode = HostProfile.Remote
        if flags & self.Reverse:
            swapHost         = self._sourceHost
            self._sourceHost = self._targetHost
            self._targetHost = swapHost
            sourceMode = HostProfile.Remote
            targetMode = HostProfile.Local

        sourceProfile = self._hostProfiles.get(self._sourceHost)
        targetProfile = self._hostProfiles.get(self._targetHost)

        if not sourceProfile:
            print( "[ERROR] Source host <%s> doesn't exists." % self._sourceHost )
            sys.exit(1)

        if not targetProfile:
            print( "[ERROR] Target host <%s> doesn't exists." % self._targetHost )
            sys.exit(1)

        options         = [] 
        optionsAsString = ""
        if flags & self.DryRun:
            options         += [ "--dry-run" ]
            optionsAsString +=   "dry-run"
        if flags & self.Merge:
            if len(optionsAsString): optionsAsString += ","
            optionsAsString += "merge"
        else:
            options += [ "--delete" ]

        logging.info("Synchronisation: <%s(%s)> => <%s(%s)>" \
                    % (sourceProfile.host,sourceProfile.fqdn
                      ,targetProfile.host,targetProfile.fqdn))

        print( "Synchronisation: <%s(%s)> => <%s(%s)>" \
            % (sourceProfile.host,sourceProfile.fqdn
              ,targetProfile.host,targetProfile.fqdn) )
        print( "  Options:" )
        print( "    %s" % (options) )

        print( "  Source:" )
        for name in labels:
            treeDir = sourceProfile.getTreeDir(name, sourceMode)
            if treeDir:
                print( "%8s:           %s" % (name,treeDir) )

        print( "  Target:" )
        for name in labels:
            treeDir = targetProfile.getTreeDir(name, targetMode)
            if treeDir:
                print( "%8s:           %s" % (name,treeDir) )
        print( "SSH args:           %s" % targetProfile.sshArgs )

        for name in labels:
            sourceTreeDir = sourceProfile.getTreeDir(name,sourceMode)
            targetTreeDir = targetProfile.getTreeDir(name,targetMode)
            if not sourceTreeDir or not targetTreeDir: continue

            print( "  Synching <%s> tree:" % name )
            logging.info("  Synching tree (%s)." % optionsAsString)
            logging.info("    source: %s" % (sourceTreeDir))
            logging.info("    target: %s" % (targetTreeDir))

            command  = [ "/usr/bin/rsync" ]
            command += options
            command += targetProfile.getRmode(name)
            command += targetProfile.getExcludes(name)
            command += targetProfile.sshArgs
            command += [ sourceTreeDir ]
            command += [ targetTreeDir ]

            self._printTable(command,4,2)
            self._execute(command, "rsync of <%s> failed " % name)
            logging.info("  <%s> synch success." % name)

        if targetProfile.postScript:
            print( "Running post-schronisation script." )
            logging.info("Executing post synchronisation script of <%s>." % targetProfile.fqdn)
            command  = [ "/usr/bin/ssh" ]
            command += targetProfile.sshArgs
            command += [ targetProfile.fqdn ]
            command += [ '/bin/bash -c "%s"' % escapeMeta(targetProfile.postScript) ]

            for line in targetProfile.postScript.split("\n"):
                logging.info("> %s" % line)

            if not (flags & self.DryRun):
                self._execute(command, "Post synchronisation script of <%s> failed." % targetProfile.fqdn)
            logging.info("  Post synchronisation script completed.")

        return

    def cleanup ( self ):
        logging.shutdown()
        return

    def loadConf ( self, binPath, configurationFile="./rsync.conf" ):
        global standardBinPathes

        rsync = self

        pathComponents = os.path.abspath(binPath).split(os.sep)
        rsyncDir       = os.sep.join(pathComponents[:-1]) 

        if rsyncDir in standardBinPathes:
            configurationFile = "/etc/yum/rsync.conf"
        else:
            configurationFile = os.path.join(rsyncDir, "etc/rsync.conf")

        try:
            print( "Loading configuration: <%s>" % (configurationFile) )
            #execfile(configurationFile)
            exec(open(configurationFile).read())
        except Exception as e:
            print( "[ERROR] An exception occured while parsing the configuration file:" )
            print( "        <%s>\n" % (configurationFile) )
            print( "        You should check for simple python errors in this file." )
            print( "        (hint: %s)" % e )
            sys.exit(1)
        return


if __name__ == "__main__":
    binPath = os.path.dirname(os.path.abspath(sys.argv[0]))
    
    try:
        parser = optparse.OptionParser() 
        parser.add_option(       "--usage"  , action="store_true"               , dest="usage"  , help="Print detailed usage/help.")
        parser.add_option( "-S", "--sync"   , action="store_true"               , dest="sync"   , help="Activate the effective transfert. By default we work in dry run mode.")
        parser.add_option( "-M", "--merge"  , action="store_true"               , dest="merge"  , help="Run in merge mode (do not delete extra files).")
        parser.add_option( "-R", "--reverse", action="store_true"               , dest="reverse", help="Pull the distribution from the target instead of pushing it.")
        parser.add_option(       "--all"    , action="store_true"               , dest="all"    , help="Synchronize all repositories.")
        parser.add_option(       "--target" , action="store"     , type="string", dest="target" , help="The target host on which to synchronize.")
        parser.add_option( "-c", "--conf"   , action="store"     , type="string", dest="conf"   , help="Rsync configuration file (default: ./rsync.conf)")
        (options, labels) = parser.parse_args ()
    
        if not options.target:
            print( "[ERROR] Missing <--target=HOST> argument." )
            sys.exit(1)

        rsync = Rsync()
        rsync.loadConf( binPath )
    
        flags = 0
        if not options.sync:    flags = flags | Rsync.DryRun
        if     options.merge:   flags = flags | Rsync.Merge
        if     options.reverse: flags = flags | Rsync.Reverse
    
        rsync.run(options.target,flags,labels)
        rsync.cleanup()
    except ErrorMessage as e:
        print( e )
        sys.exit(1)
    except KeyboardInterrupt as e:
        print( "[ERROR] Aborted by user (CTRL+C)." )

    sys.exit(0)
