#!/usr/bin/env python3

import argparse
import configparser
import os
import signal
import socket
import stat
import sys
import time

import localutil

def timeout(func):
    def f(*args, **kwargs):
        signal.alarm(10)
        ret = func(*args, **kwargs)
        signal.alarm(0)
        return ret
    return f

def alarm_handler(signum, frame):
    raise IOTimeoutError()

class CheckBackupsApp(object):
    RC_OK = 0
    RC_BACKUPS_OUTDATED = 1
    RC_IO_TIMEOUT = 2

    def __init__(self):
        self.configure_options()
        self.hostname = socket.gethostname().split('.')[0]
        signal.signal(signal.SIGALRM, alarm_handler)

    def configure_options(self):
        program_description = """
            Verifies that rsnapshot backups in the given backup directory (rsnapshot_master_root) are recent. The backup
            directory should contain one or more snapshot_root directories as specified in the rsnapshot configuration
            file.
        """
        a = argparse.ArgumentParser(description=program_description)
        a.add_argument(
            '-c', '--config-file', dest='config_path', default='check-backups.conf',
            help='path to the configuration file'
        )
        self.argument_parser = a

    def parse_config(self, config_path):
        c = configparser.ConfigParser()
        c.read(config_path)
        self.rsnapshot_master_root = c.get('global', 'rsnapshot_master_root')
        self.notify = c.get('global', 'notify')
        try:
            self.grace_period = c.getint('global', 'grace_period')
        except configparser.NoOptionError:
            self.grace_period = 1800

    @timeout
    def get_rsnapshot_roots(self):
        r = self.rsnapshot_master_root
        rsnapshot_roots = filter(
            lambda f: stat.S_ISDIR(os.lstat(f).st_mode),
            map(lambda path: os.path.join(r, path), os.listdir(r))
        )
        return rsnapshot_roots

    @timeout
    def check_backups(self, rsnapshot_roots):
        bad_backups = list()
        backup_checker = RsnapshotBackupChecker()
        for root in rsnapshot_roots:
            if not backup_checker.backup_ok(root, self.grace_period):
                bad_backups.append(root)
        if bad_backups:
            subject = '{0}: outdated backup warning'.format(self.hostname)
            message = 'The following backups are out of date:\n{0}'.format(
                '\n'.join(bad_backups)
            )
            localutil.send_email(self.notify, subject, message)
            return False
        return True

    def run(self, argv):
        self.configure_options()
        args = self.argument_parser.parse_args(argv)
        self.parse_config(args.config_path)
        try:
            rsnapshot_roots = self.get_rsnapshot_roots()
            if not self.check_backups(rsnapshot_roots):
                return self.RC_BACKUPS_OUTDATED
        except IOTimeoutError:
            subject = '{0}: IO to backup directory timed out!'.format(self.hostname)
            message = 'While attempting to check backups, IO to backup directory timed out.'
            localutil.send_email(self.notify, subject, message)
            return self.RC_IO_TIMEOUT
        return self.RC_OK


class RsnapshotBackupChecker(object):
    # KEYWORD_AGE is a dict mapping an rsnapshot backup interval keyword to the expected age of the most recent
    # backup for that interval
    #
    # for example, we expect the most recent hourly backup to be no older than one hour (3600 seconds)
    KEYWORD_AGE = dict()
    KEYWORD_AGE['hourly'] = 60 * 60
    KEYWORD_AGE['daily'] = KEYWORD_AGE['hourly'] * 24
    KEYWORD_AGE['weekly'] = KEYWORD_AGE['daily'] * 7

    def most_recent_rsnapshot_rotation(self, directory):
        """
        Returns the most recently rsnapshot rotation in directory
        """
        flist = map(lambda f: os.path.join(directory, f), os.listdir(directory))
        dlist = filter(lambda f: stat.S_ISDIR(os.lstat(f).st_mode), flist)
        rsnapshot_dlist = filter(lambda d: self.is_rsnapshot_backup_rotation(d), dlist)
        try:
            most_recently_modified_directory = max(rsnapshot_dlist, key=lambda f: os.stat(f)[stat.ST_MTIME])
        except ValueError as e:
            raise NotRsnapshotRootError('{0} does not seem to be an rsnapshot directory'.format(directory)) from e
        return most_recently_modified_directory

    @classmethod
    def is_rsnapshot_backup_rotation(cls, path):
        return cls.path_rsnapshot_keyword(path) in cls.KEYWORD_AGE.keys()

    @classmethod
    def path_rsnapshot_keyword(cls, path):
        filename = os.path.basename(path)
        return filename.split('.')[0]

    def backup_ok(self, rsnapshot_root, grace_period):
        try:
            directory_to_check = self.most_recent_rsnapshot_rotation(rsnapshot_root)
        except NotRsnapshotRootError:
            return False
        rsnapshot_keyword = self.path_rsnapshot_keyword(directory_to_check)
        directory_age = time.time() - os.stat(directory_to_check)[stat.ST_MTIME]
        return (directory_age < self.KEYWORD_AGE[rsnapshot_keyword])


class NotRsnapshotRootError(Exception): pass
class IOTimeoutError(Exception): pass

if __name__ == '__main__':
    CheckBackupsApp().run(sys.argv[1:])
