#!/bin/env python
#
# mkbackup.py
#
# (c) Alain Spineux alain.spineux@gmail.com
#
# mkbackup is a front-end for popular and free linux and windows archiver tools.
#
# Supported archiver:
# - ntbackup
# - wbadmin (next to come)
# - tar (next to come)

# Features:
# - job definitions are stored in a configuration file
# - the destination of the archive can be set to different location depending
#   on the day, the week or the month to allow very complex schema of backup
# - send and email at the end of job
# - the email contains all information to evalute the reliability of the backup jobs
#
#
# mkbackup is released under the GNU GPL license
#

import sys, os, subprocess, time, smtplib, calendar, re, socket, urllib2, logging, logging.handlers, codecs
import traceback, StringIO, platform 
from optparse import OptionParser
import ConfigParser

from datetime import datetime, timedelta

from archiver import boolean, check_mail_config, Destinations, sendmail, Manager, send_mail_report

import cron

__version__='0.8.5b'
last_version_url='http://www.magikmon.com/download/mkbackup_last.txt'


def gen_archiver(name):
    name=name.lower()
    if name=='ntbackup':
        import ntbackup
        return ntbackup.NtBackup
    elif name=='tar':
        import tar
        return tar.Tar
    elif name=='ghettovcb':
        import ghettovcb
        return ghettovcb.Ghettovcb
    elif name=='wbadmin':
        import wbadmin
        return wbadmin.Wbadmin
    elif name=='wbadminsys':
        import wbadmin
        return wbadmin.WbadminSys
    else:
        return None


# ---------------------------------------------------------------------------
def update_check():
    """Check if and new version of MKBackup is available and return the annonce
    message if available
    """
    up2date, msg='yes', ''
    try:
        data=urllib2.urlopen(last_version_url).read()
        lines=data.split('\n')
        current=__version__.split('.')
        last=lines[0].split('.')
        for i in range(min(len(current), len(last))):
            if int(current[i])<int(last[i]):
                up2date='no'
                msg='\n'.join(lines[1:])
                break
            
    except urllib2.URLError:
        up2date='unk'
    except Exception, e:
        up2date='unk'

    return up2date, msg



# ---------------------------------------------------------------------------        
def mail_exception(start, job, extra, attachements):
    
    end=int(time.time())
    subject='MKBACKUP BACKUP ERR %s' % (job['name'], )
    msg_body=job['msg_header']
    msg_body+='\nUnexpected error, read "traceback.txt" for more\n\n'
    msg_body+=extra+'\n'
    tb=StringIO.StringIO()
    traceback.print_exc(None, tb)
    status=u''
    status+='name=%s\r\n' % job['name']
    status+='program=%s\r\n' % job.get('program','')
    status+='version=%s\r\n' % __version__
    status+='status=ERR\r\n'
    status+='hostname=%s\r\n' % platform.node()
    status+='runtime_error=%s\r\n' % sys.exc_info()[1]
    status+='start=%s\r\n' % time.ctime(start)
    status+='end=%s\r\n' % time.ctime(end)
    status+='start_epoch=%d\r\n' % start
    status+='end_epoch=%d\r\n' % end


    attachements=attachements[:]
    attachements+=[ # the type, subtype and coding is not used
           ('traceback.txt', None, tb.getvalue().encode('utf-8'), 'text', 'plain', 'utf-8'),
           ('status.txt', None, status.encode('utf-8'), 'text', 'plain', 'utf-8'),
    ]
    tb.close()
    sendmail(job['sender'], job['recipients'], subject, msg_body, attachements, job['smtp_host'], job['smtp_port'])


#======================================================================
#
# Main
#
#======================================================================

#
# Parse the command line
#
parser=parser=OptionParser(version='%%prog %s' % __version__ )
parser.set_usage('%prog [options] command [job..]\n\n'
                  '\tcommand in "backup", "check", "checkmail"')

parser.add_option("-c", "--config", dest="config", default='mkbackup.ini', help="use another ini file", metavar="configfile")
parser.add_option("-d", "--debug", dest="debug", action="store_true", default=False, help="switch to debug level")
parser.add_option("-v", "--verbose", dest="verbose", action="store_true", default=False, help="write log to the terminal")
parser.add_option("-l", "--logfile", dest="logfile", default='mkbackup.log', help="log to this file", metavar="logfile")
parser.add_option("-e", "--encoding", dest="encoding", default='auto', help="configuration file encoding", metavar="encoding")

cmd_options, cmd_args=parser.parse_args(sys.argv)

print 'logging in %s' % cmd_options.logfile
root_handler=logging.handlers.RotatingFileHandler(cmd_options.logfile, 'a', 1024**2, 2)
root_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)-3.3s %(message)s', '%Y-%m-%d %H:%M:%S'))
log=logging.getLogger()
log.setLevel(logging.INFO)
log.addHandler(root_handler)

if cmd_options.debug:
    log.setLevel(logging.DEBUG)
else:
    # this stop early the logging process of loglevel<=DEBUG and should 
    # improve performance a little bit
    logging.disable(logging.DEBUG)

if cmd_options.verbose:
   console=logging.StreamHandler()
   console.setFormatter(logging.Formatter('%(asctime)s,%(msecs)03d %(levelname)-3.3s %(message)s', '%H:%M:%S'))
   log.addHandler(console)


log.info('start %r', sys.argv)

if len(cmd_args)<2:
    parser.error('no "command" set')
    
command=cmd_args[1]
if command.lower() not in ('backup', 'check', 'checkmail'):
    parser.error('invalid command "%s"' % command)

command=command.lower()

job_list=cmd_args[2:]

if not job_list:
    parser.error('no "job"')


#
# load the configuration file
#
config_default=dict(verify='no',
                    update_check='yes',
                    smtp_port='25',
                    smtp_host='127.0.0.1',
                    night_shift='yes',
                    )

try:
    config_file=open(cmd_options.config, 'r')
except IOError, e:
    log.error('error reading configuration file "%s": %s', cmd_options.config, e)
    sys.exit(1)

config_text=config_file.read()
config_file.close()
config_file=None

if cmd_options.encoding=='auto':
    # try to guess the file encoding  
    bomdict = { codecs.BOM_UTF8 : ('utf_8', 1),  # or ( 'utf_8_sig', 0) but utf_8_sig dont exist in 2.4
                codecs.BOM_UTF16_BE : ('utf_16', 0),
                codecs.BOM_UTF16_LE : ('utf_16', 0) }

    for bom, (encoding, skip) in bomdict.items():
        if config_text.startswith(bom):
            cmd_options.encoding=encoding
            cmd_options.skip=skip
            config_file=codecs.open(cmd_options.config, 'r', encoding)
            if skip:
                config_file.read(skip)
            break
    
    if cmd_options.encoding=='auto':
        # use default per platform
        if sys.platform in ('win32', ):
            cmd_options.encoding='windows-1252'
        else:
            cmd_options.encoding='UTF8'

        config_file=codecs.open(cmd_options.config, 'r', cmd_options.encoding)
else:
    try:
        config_file=codecs.open(cmd_options.config, 'r', cmd_options.encoding)        
    except LookupError, e:
        log.error('error reading configuration file %s: %s', cmd_options.config, e)
        sys.exit(1)
        

config=ConfigParser.RawConfigParser(config_default)
try:
    config.readfp(config_file)
except (IOError, UnicodeDecodeError), e:
    log.error('error reading configuration file %s: %s', cmd_options.config, e)
    sys.exit(1)

up2date, up2date_msg='unk', ''
check_for_update=config.get('DEFAULT', 'update_check')
if boolean.get(check_for_update.lower(), None)!=False:
    up2date, up2date_msg=update_check()
    if up2date=='no':
        log.warning('!'*60)
        for line in up2date_msg.split('\n'):
            if line:
                log.warning(line)
        log.warning('!'*60)

err, failed_job=0, job_list[:]
for job_name in job_list:
    try:
        program=config.get(job_name, 'program')
    except ConfigParser.NoOptionError, e:
        log.error('no "program" set for job "%s"',job_name)
        err+=1
    except ConfigParser.NoSectionError, e:
        log.error('job not found: %s',job_name)
        err+=1
    else:
        if not gen_archiver(program):
            log.error('program "%s" unknown in job "%s"', program, job_name)
            err+=1
        else:
            failed_job.remove(job_name)

if err>0:
    parser.error('in jobs: %s' % ' '.join(failed_job))
    sys.exit(3)

exit_code=0


manager=Manager(platform.node(), log)

for job_name in job_list:

    manager.start_logging()

    now=datetime.now()
    manager.now=now
    start=int(time.time())
    job=dict(config.items(job_name))
    program=job.get('program')
    arch=gen_archiver(program)()
    arch.__version__=__version__
    arch.night_shift=boolean.get(job.get('night_shift', 'on').lower(), True)
    job=dict(config.items(job_name))
    job['name']=job_name
    job['config']=cmd_options.config
    job['config_encoding']=cmd_options.encoding
    
    if up2date=='no':
        job['msg_header']='\n%s\n' %up2date_msg
    else:
        job['msg_header']=''

    attachements=[ ('config.ini', job['config'], None, 'text', 'plain', job['config_encoding']), ] 

    mail_config_errors=check_mail_config(job)

    # check job parameters 
    
    try:
        errors, warnings, extra=arch.load(job, manager)
    except Exception, e:
        log.exception('loading job %s by program %s', job_name, program)
        if job['mail'] and not mail_config_errors:
            mail_exception(start, job, '', attachements)
        continue

    for line in extra.split('\n'):
        if line:
            log.info('    %s', line)

    errors.update(mail_config_errors)
    
    msg_body=job['msg_header']
        
    if errors:
        exit_code=3
        msg_body+='Error in section: %s\n\n' % job['name']
        log.error('Error in section: %s', job['name'])
        for k, v in errors.iteritems():
            if job.get(k, None):
                log.error('%s=%s %s', k, job.get(k,''), v)
                msg_body+='%s=%s\n    %s\n\n' % (k, job.get(k,''), v)
            else:
                log.error('%s: %s', k, v)
                msg_body+='%s\n    %s\n\n' % (k, v)
                
        if job['mail'] and not mail_config_errors and not command=='check':
            subject='MKBACKUP CONFIG ERR %s ' % (job['name'], )
            sendmail(job['sender'], job['recipients'], subject, msg_body, [], job['smtp_host'], job['smtp_port'])

        continue

    log.info('No error in section: %s', job['name'])

    destination=getattr(arch, 'destination', None)
    
    if command in ('check', 'checkmail'):
        msg_body=job['msg_header']
        if destination and isinstance(destination, Destinations): 
            msg_body+='Destinations day by day:\n\n'
            log.info('Destinations day by day:')
            for i in range(destination.max_weekdivisor*7):
                today=now+timedelta(days=i)
                typ, target=destination.match(today, night_shift=arch.night_shift)
                if typ=='none':
                    ty='none'
                    target='-----------------'
                elif typ==None:
                    ty=''
                    target='??? no target ???'
                else:
                    ty=typ
                msg_body+='    %s %-12s %s\n' % (today.strftime('%a %d %b %Y'), ty, target)
                log.info('    %s %-12s %s', today.strftime('%a %d %b %Y'), ty, target)
                if typ!='none' and typ!=None and not os.path.isdir(os.path.dirname(target)):
                    msg_body+='        directory not found !!!\n'
                    log.warning('        directory not found: %s', os.path.dirname(target))
                    
        msg_body='%s\n\n%s\n' % (msg_body, extra)
        if job['mail'] and command=='checkmail':
            subject='MKBACKUP CONFIG OK %s ' % (job['name'], )
            sendmail(job['sender'], job['recipients'], subject, msg_body, [], job['smtp_host'], job['smtp_port'])
        continue 

    #print 'destination', destination
    if destination and isinstance(destination, Destinations): 
        typ, target=destination.match(now, night_shift=arch.night_shift)
        if typ=='none' or typ==None:
            log.info('bye, no backup today')
            continue

    log.info('start command=%s job=%s archiver=%s', command, job_name, program)
    try:
        result=arch.run(command, job, manager)
    except Exception, e:
        exit_code=2
        log.exception('running command=%s job=%s archiver=%s', command, job_name, program)
        if job['mail']:
            mail_exception(start, job, extra, attachements)
    else:
        log.info('end command=%s job=%s archiver=%s', command, job_name, program)

        if isinstance(result, int):
            exit_code=max(exit_code, result)
        else:
            code=send_mail_report(job, result, manager)
            exit_code=max(exit_code, code)
            

sys.exit(exit_code)

#<full,first su>S:\monthly\data-full-month-${month%2}.bkf
#<full,su>S:\week${week%4}\data-full-%a.bkf
#<inc,mo-sa>S:\week${week%4}\data-inc-%a.bkf



#<full,week%4=0>S:\data-full.bkf
#<inc,week%4=1>S:\data-inc.bkf

#
#w1/4
#jan/2,su
#feb-dec/2


# when start a week
# firstday=sat
#last day OF THE MONTH
#1st mon,2nd tue,3th wed,4th thu,last sat  OF THE MONTH
#1st week,2nd week,3th week,4th week,last week OF THE MONTH
#<> => empty selector to allow "no backup"
# empty target => no backup



# http://www.scalabium.com/faq/dct0082.htm
# To detect the code page of Windows operation system you must call the GetACP function from Windows API.
# If you needs to read the code page of "DOS" sessions, you must call the GetOEMCP function from Windows API. This function will return the value:
# http://msdn.microsoft.com/en-us/library/dd318070%28VS.85%29.aspx

#


# windows unicode file http://www.helpware.net/FAR/help/Unicode2.htm
# Universal Encoding Detector http://chardet.feedparser.org/docs/supported-encodings.html


# wbadmin
#
# How to use wbadmin to backup in Windows Vista 2008
# http://www.howtonetworking.com/Windows/wbadmin1.htm
#
# Backup Version and Space Management in Windows Server Backup
# https://blogs.technet.com/filecab/archive/2009/06/22/backup-version-and-space-management-in-windows-server-backup.aspx
#
# Customizing Windows Server Backup Schedule
# http://blogs.technet.com/filecab/archive/2009/04/13/customizing-windows-server-backup-schedule.aspx
#
# %windir%\logs\windowsserverbackup
#
# Output of command "Vshadow -wm2" ran on an elevated command prompt
#
# Deleting System State Backup:
# wbadmin delete systemstatebackup -deleteOldest
# wbadmin delete systemstatebackup -keepversions:10   
# The above command will keep the latest 10 versions and delete the rest all the system state backups.
#
# Deleting other backups:  Backup application stores multiple backup versions in the VSS shadow copies. Hence, older backup version can be deleted by deleting older shadow copy. Commands to list and delete VSS shadow copies are below. They need to be run in an elevated command window.
# vssadmin list shadows /for=x: ? for listing the snapshots on x: where x: is the backup location
# vssadmin delete shadows /for=x:  /oldest ? for delete the oldest shadow copy. It can be called multiple times in case there is need to delete multiple older backups.
# Note: wbadmin get versions or backup UI would still report deleted backups until next backup runs. At end of each backup ? non-existent backups are removed from the backup catalog.
#
# Win2k8: the backups are stored inside the <drive_letter>:\WindowsImageBackup\<machineName>\SystemStateBackup
# Win2k8 R2: the backups are stored inside the <drive_letter>:\WindowsImageBackup\<machineName>\BackupSet<..>\ path

