#!/usr/bin/python3
""" A script to clean unused files on a CompletePBX system

The script is concerened with free space in the root partition This is
because when there's no free space in the root partition, CompletePBX
may fail to work (varius logging, records in the MySQL database, some
temporary files).

In a Twin-Star system, MySQL databases and most other files this script
checks for reside on the replica partition. Thus the script considers
it as the main partition instead of /.

This script does not attempt to guard against any other partition
getting filled.

The script includes a set of cleaner modules. It has a number of
parameters:
* threshold: The script will do nothing if the used space is below
  the threshold. 0.95 means 95% of the root partition is used.
* allowed usage: Some of the modules may only take up to a certain rate
  of the disk space. They will take no action unless their files take
  more than that rate of the disk space. E.g. the recording module
  will only start cleaning if recordings take more than 50% of the disk
  space.

Usage: The following command-line options prevent the script from actual
cleaning and are thus safe to run:
* -h --help
* --list: Do nothing and just list the cleaning candidate modules
* -s --status: Calculate and show disk usage and usage by files of each
  module. Each module is prepended with either:
  * '[Y]': all's well
  * '[N]': need cleaning
* -d --no-act: Show what will be cleaned.

There are a number of other command-line arguments to set values for
various parameters, such as --threshold (-t) to set the threshold.
"""


import argparse
import glob
import json
import os
import re
import subprocess
import sys


# Those defaults are intended to be safe for even the smallest system we
# ship: the Spark, with a 7GB root file system.
# It has roughly 25% used by system files.
ALLOWED_USAGE = {
    'backups': 0.20,
    'recordings': 0.50,
    'astlogs': 0.05,
}
BACKUPS_NUM = 2
LOG_FILE_MAX_SIZE = 10  # 10MB
THRESHOLD = 0.95
THRESHOLD_TARGET = 0.90
THRESHOLD_MAX = 1
# PArtitions to check disk space in:
PARTITIONS = ['/', '/replica']
# Path that identifies the main partition
MAIN_PARTITION_PATH = '/var/lib/mysql'
ERROR_FAILED_TO_CLEAR = 4
APT_CACHE_EMPTY_MAX_SIZE = 30 * 1024

CFG = None

DISK_SPACE = None

# Set by DiskSpace. For use by the Cleaner classes
root_part_size = None


# Size that was "deleted" by no-act actions
no_act_deleted_size = 0


def log(msg):
    """ Log a message """
    print(msg)


def log_verbose(msg):
    """ Log a message in verbose mode """
    if CFG.verbose:
        log(msg)


def format_size(size):
    """ Pretty-print a size in bytes """
    format_str = "{:,}" if type(size) == int else "{:,.1f}"
    if size == 0:
        return '0'
    if abs(size) < 1024:
        return str(size)
    if abs(size) < 1024 * 1024:
        return format_str.format(size / 1024) + 'k'
    if abs(size) < 100 * 1024 * 1024 * 1024:
        return format_str.format(size / 1024 / 1024) + 'M'
    return format_str.format(size / 1024 / 1024 / 1024) + 'G'


def pretend_delete(size=0, files=[]):
    """ Pretend we delete file(s) and update "free disk space".

    Either give size cleared, or a list of files that were "deleted".
    """
    global no_act_deleted_size

    if size == 0:
        for fn in files:
            size += os.stat(fn).st_blocks * 512
    no_act_deleted_size += size


class Cleaner(object):
    """ A parent class for all file removal candidate modules """

    # The Cleaners registered:
    modules_dict = {}

    # The ones to use, in the order to use:
    modules_list = []

    def __init__(self, name, path):
        self.name = name
        self.path = path
        self.used = None
        Cleaner.modules_list.append(name)
        Cleaner.modules_dict[name] = self

    def update_usage(self):
        """ Update disk usage. May take a while, thus needs explicit call. """
        used = '0'
        if self.mount_point() not in PARTITIONS:
            self.used = 0
            return
        try:
            with open(os.devnull, 'w') as devnull:
                output = subprocess.check_output(['du -bsxc ' + self.path],
                                                 stderr=devnull, shell=True).decode('utf-8')
        except subprocess.CalledProcessError as exception:
            output = exception.output
        for line in output.split('\n'):
            parsed = re.split(r'\s+', line)
            if len(parsed) == 2:
                used = parsed[0]  # The last line, "total", wins
        self.used = int(used)

    def mount_point(self):
        """ Check the mount point on which this is mounted

        Specifically: the first component of self.path that exists. If
        none exists: return ''.
        """
        try:
            with open(os.devnull, 'w') as devnull:
                output = subprocess.check_output(['df --output=target ' +
                                                  self.path],
                                                 shell=True, stderr=devnull).decode('utf-8')
        except subprocess.CalledProcessError:
            return ''  # A safe default
        return re.split(r'\s+', output)[2]   # From second line

    def usage_rate(self):
        """ What rate of the disk is used by files of this candidate """
        return float(self.used) / root_part_size

    def allowed_rate(self):
        """ What is the usage rate this candidate's files are alloed """
        key = self.name + '_rate'
        if not hasattr(CFG, key):
            return 0
        return getattr(CFG, key)

    def is_usage_ok(self):
        """ Is the usage of this candidate OK?

        Often overriden by subclasses.
        """
        return self.usage_rate() <= self.allowed_rate()

    def size_to_clean(self):
        """ Size left to clean. Assuming is_usage_ok returned false """
        rate_allowed = self.usage_rate() - self.allowed_rate()
        rate_thresh = DISK_SPACE.threshold_limit_rate()
        rate = rate_allowed if rate_allowed < rate_thresh else rate_thresh
        return root_part_size * rate

    def clean_cmd_str(self):
        """ A simple cleaner will just define a single command here """
        pass

    def clean(self):
        """ Cleaner function. Calls cleaner() to do the job """
        if CFG.no_act:
            log("I: {}: would run: {}".format(self.name,
                                              self.clean_cmd_str()))
            log("I: {}: that would free: {}".format(self.name,
                                                    format_size(self.used)))
            pretend_delete(size=self.used)
        else:
            log_verbose("I: {}: Running: {}".format(self.name,
                                                    self.clean_cmd_str()))
            self.cleaner()

    def get_timestamp(self, file_name, stat):
        """ Returns the time stamp of a file.

        At the time of the call we already have the stat output.
        However for overriding it may be useful to look at the original
        file.
        """
        del file_name
        return stat.st_ctime

    def get_delete_candidates(self):
        """ Returns the potential Asterisk full log files """
        return glob.glob(self.path)

    def clean_multiple(self, minimal_num=0, file_max_size=0):
        """ A cleaner of multiple files

        This cleaner also needs to consider a maximal rate that files
        of that type may take (size_to_clean()).

        It will get a list of files to clean (typically: from the glob
        of the path, but generally: get_delete_candidates()). It will
        sort them by time and start deleting them. It will stop when
        either
        * It has reached the target threshold, or
        * Files of that type now take below their allowed rate.

        It also has two extra knobs:
        * minimal_num: keep at least this many files (the newest).
        * file_max_size: files below that size (in MB) will not be touched.
        """
        candidate_files = self.get_delete_candidates()
        if len(candidate_files) <= minimal_num:
            return
        size_to_clean = size_start = self.size_to_clean()
        if size_to_clean <= 0:
            return

        candidates_data = []
        for file_name in candidate_files:
            stat = os.stat(file_name)
            size = stat.st_blocks * 512
            time_stamp = self.get_timestamp(file_name, stat)
            candidates_data.append({'file': file_name, 'time': time_stamp,
                                    'size': size})
        # Make sure we don't delete very small (asterisk logs):
        candidates_data = [c for c in candidates_data
                           if c['size'] >= file_max_size]

        # Keep a minimal number (of backups)
        max_num_to_delete = len(candidate_files) - minimal_num

        candidates_by_time = sorted(candidates_data, key=lambda b: b['time'])
        candidates_to_delete = []
        for candidate_data in candidates_by_time:
            candidates_to_delete.append(candidate_data)
            size_to_clean = size_to_clean - candidate_data['size']
            if size_to_clean <= 0:
                break

            max_num_to_delete = max_num_to_delete - 1
            if max_num_to_delete == 0:
                break

        deleted_candidats = []
        for candidate in candidates_to_delete:
            file_name = candidate['file']
            if CFG.no_act:
                log("I: Would delete: ({}) {}".
                    format(format_size(candidate['size']), file_name))
                pretend_delete(files=[file_name])
            else:
                log_verbose("I: Deleting " + file_name)
                os.unlink(file_name)
                deleted_candidats.append(file_name)
        if CFG.no_act or CFG.verbose:
            size_cleaned = size_start - size_to_clean
            if size_cleaned > 0:
                log("I: Cleaner {}: Total cleaned: {}".
                    format(self.name, format_size(size_cleaned)))
        return deleted_candidats

    def cleaner(self):
        """ Run the actual cleaning.

        By default runs clean_cmd_str()
        """
        subprocess.check_call(self.clean_cmd_str(), shell=True)

    def __str__(self):
        """ String representation showing the current status """
        name_str = "{} {}".format(" " if self.is_usage_ok()
                                  else "!", self.name)
        used_str = "used: {} ({:.1%})".format(format_size(self.used),
                                              float(self.used) /
                                              root_part_size)
        allowed_str = "allowed: {:.1%}".format(self.allowed_rate())
        return "{:<16}{:<22}{:<16}{}".format(name_str, used_str,
                                             allowed_str, self.path)

    @staticmethod
    def modules():
        """ Return the modules that will be used """
        return [Cleaner.modules_dict[name]
                for name in Cleaner.modules_list]

    @staticmethod
    def set_modules(modules_list):
        """ Set the modules (name, and order) to use """
        for name in modules_list:
            if name not in Cleaner.modules_dict:
                log("E: candidate {} is not a valid candidate name")
                return False
        Cleaner.modules_list = modules_list
        return True


class Backups(Cleaner):
    """ Cleaner module for extra CompletePBX backups.

    CompletePBX backups reside under /var/lib/ombutel/static/backup.

    This module has a parameter for number of backups to keep (default:
    2). It will not delete the last <num> backups.

    Other than that, it will try to delete backups, starting with older
    ones. It determains the age of a backup file by reading its metadata.
    Up until recording take no more that the specified rate of the
    disk space (by default: 20%).
    """
    def __init__(self):
        super(Backups, self).__init__('backups',
                                      '/var/lib/ombutel/static/backup')

    def get_delete_candidates(self):
        """ A list of backup files """
        tars = glob.glob('{}/*.tar'.format(self.path))
        zips = glob.glob('{}/*.zip'.format(self.path))

        return tars + zips

    def get_timestamp(self, file_name, stat):
        """ Get a time stamp of a backup tarball/zip from its metadata.

        As a fallback, use the time stamp from the file system.
        """
        try:
            cmd =''
            if file_name.endswith('.tar'):
                cmd = 'tar xOf {} info.json.gz | zcat'.format(file_name)
            elif file_name.endswith('.zip'):
                cmd = 'unzip -c {} info.json.gz | zcat'.format(file_name)
            if len(cmd) != 0:
                js = subprocess.check_output(cmd, shell=True).decode('utf-8')
                if len(js) > 0:
                    data = json.loads(js)
                    return data['created']
                else:
                    return super(Backups, self).get_timestamp(file_name, stat)
            else:
                return super(Backups, self).get_timestamp(file_name, stat)
        except subprocess.CalledProcessError:
            return super(Backups, self).get_timestamp(file_name, stat)


    def clean(self):
        return self.clean_multiple(minimal_num=CFG.backups_num)

class Recordings(Cleaner):
    """ Cleaner module for extra CompletePBX call recording files.

    CompletePBX call recordings reside under /var/spool/asterisk/monitor .

    This module will try to delete recording files, starting with older
    ones. Up until recording take no more that the specified rate of the
    disk space (by default: 60%).
    """
    def __init__(self):
        super(Recordings, self).__init__('recordings',
                                         '/var/spool/asterisk/monitor/')

    def clean(self):
        return self.clean_multiple()

    def get_delete_candidates(self):
        """ Return all files under the recordings directory.

        Note: there is no check if some of them are under a different
        file system. Hopefully nobody mounts just part of the recording
        directory under a different file system.
        """
        recordings = []
        for root, dirs, files in os.walk(self.path):
            del dirs
            for f in files:
                full_path = os.path.join(root, f)
                recordings.append(full_path)
        return recordings


class AsteriskLogs(Cleaner):
    """ Cleaner module for the asterisk full log

    Those logs reside by default under /var/log/asterisk.
    We handle here only the 'full' and 'fail2ban' logs.

    This module has two knobs:
    * Disk usage rate. By default: 5%. Set with -a --astlogs-rate .
      If the logs don't take more than this rate, no attempt would be
      made to delete them.
    * Maximal file size. By default 10MB. Set with -l --log-max-size .
      If a log file is not larger than this size (in MB), no attempt will
      be made to delete it.

    Normally the asterisk full log is kept at a reasonable size. This
    should probably only be needed if there is a sudden burst of
    excessive logging.

    Like other modules, it will try to delete older files first.
    """
    def __init__(self):
        super(AsteriskLogs, self).__init__('astlogs',
                                           '/var/log/asterisk/f[ai,ul]*')

    def clean(self):
        return self.clean_multiple(file_max_size=CFG.log_max_size * 1024 * 1024)

class YumPackages(Cleaner):
    """ Clean downloaded Yum RPM packages.

    Yum normally deletes package files from its cache after it installing
    them. This module checks if there are any such packages, and if so,
    removes them.
    """
    def __init__(self):
        path = '/var/cache/yum/*/*/*/packages/*.rpm'
        super(YumPackages, self).__init__('yum_rpms', path)

    def is_usage_ok(self):
        return self.used == 0

    def clean_cmd_str(self):
        return 'yum clean packages'


class AsteriskCores(Cleaner):
    """ Clean Asterisk core dump files.

    This module deletes asterisk core dump files. Those files are images
    of the memory of a process, and normally are generated when process
    (in this case: asterisk) terminates abnormally.

    They may be handy for debugging the cause for the crash, but they
    do take extra disk space. This module removes any such asterisk core
    dump files in /var/lib/asterisk/ .
    """
    def __init__(self):
        super(AsteriskCores, self).__init__('astdumps',
                                            '/var/lib/asterisk/core.*')

    def is_usage_ok(self):
        """ There should be no core files """
        return self.used == 0

    def clean(self):
        return self.clean_multiple()

class AptCache(Cleaner):
    """ Clean the APT packages cache.

    APT normally deletes package files from its cache after it installing
    them. This module checks if there are any such packages, and if so,
    removes them.
    """
    def __init__(self):
        super(AptCache, self).__init__('apt_cache',
                                       '/var/cache/apt/archives')

    def is_usage_ok(self):
        """ Usage check: The directory (with partial/) may take a bit """
        return self.used < APT_CACHE_EMPTY_MAX_SIZE

    def clean_cmd_str(self):
        """ Cleaner: just use apt-get clean """
        return 'apt-get clean'


class DiskSpace(object):
    """ Information about disk space usage in the main file system.

    Typically the main file system is the root file system (/). However
    on a TwinStar system it would be the /replica file system. The main
    file system is defined as the one that includes MAIN_PARTITION_PATH
    (/var/lib/mysql) with a fallback to '/' if it does not exist.
    """
    def __init__(self):
        self.size = None
        self.used = None
        self.update()

    def update(self):
        """ (Re)-check the disk usage """
        global root_part_size

        path = MAIN_PARTITION_PATH
        if not os.path.exists(path):
            path = '/'  # FIXME: exit with an error

        statvfs = os.statvfs(path)
        self.size = statvfs.f_blocks * statvfs.f_bsize
        self.used = (statvfs.f_blocks - statvfs.f_bavail) * statvfs.f_bsize
        root_part_size = self.size

    def _used(self):
        """ Consider the amount we pretend to delete in the dry run """
        return self.used - no_act_deleted_size

    def below_threshold(self):
        """ Is the space used below the configured threshold? """
        return float(self._used()) / self.size < CFG.threshold

    def below_threshold_target(self):
        """ Is the space used below the configured low threshold? """
        return float(self._used()) / self.size < CFG.threshold_target

    def usage_rate(self):
        return float(self._used()) / self.size

    def threshold_limit_rate(self):
        return self.usage_rate() - CFG.threshold_target

    def __str__(self):
        """ String representation: one-line status report """
        return "Total: {}, used: {} ({:.1%}), threshold: {:.1%} (target: {:.1%})". \
               format(format_size(self.size),
                      format_size(self._used()),
                      self.usage_rate(),
                      CFG.threshold,
                      CFG.threshold_target,
                      )


class HTMLFormatter(argparse.RawTextHelpFormatter):
    """ Format argparse help as an HTML snippet.

    Need to inherit from RawTextHelpFormatter rather than HelpFormatter
    in order to keep description / epilog text split to lines (paragraps).
    """
    def _format_action(self, action):
        """ Wrap an action line in a LI tag """
        return "<li>{}</li>".format(
            super(HTMLFormatter, self)._format_action(action))

    def _format_usage(self, usage, actions, groups, prefix):
        """ Wrap usage text in a P tag """
        return "<p>{}</p>".format(
            super(HTMLFormatter, self)._format_usage(usage, actions,
                                                     groups, prefix))

    def _format_text(self, text):
        """ Format a single section. Split to paragraphs """
        text = super(HTMLFormatter, self)._format_text(text)
        text = text.strip()
        paragrapgs = re.split(r'^\s*$\n', text, flags=re.M)
        html_pars = []
        for p in paragrapgs:
            if p in ['</ul>']:
                html_pars.append(p)
            else:
                html_pars.append("<p>{}</p>".format(p))
        return "\n".join(html_pars)

    def start_section(self, heading):
        """ A title and beginning of group UL.

        FIXME: needs extra cleanup of the ':' after the <ul>.
        """
        text = "<p>{}</p>\n<ul>".format(heading)
        super(HTMLFormatter, self).start_section(text)

    def end_section(self):
        """ Close list of items. FIXME: leaves an extra </ul> """
        super(HTMLFormatter, self).end_section()
        self.add_text('</ul>')


def parse_cmd_line(args=sys.argv[1:]):
    """ Parse command-line arguments into the global CFG """
    global CFG

    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawTextHelpFormatter)
    parser.description = """
    This script ensures that there is minimal sufficient free hard disk
    space for proper PBX behavior. It checks the available disk space
    and if it is lower than configurable threshold value then the script
    will free disk space by deleting files on the main partition.

    The script checks the proportion (rate) of disk space taken by
    different classes of files: call recordings, log files, backups etc
    and then deletes the oldest files accordingly.

    It will only attempt at cleaning if the disk usage in the main
    partition is above the threshold (by default: {}). It will generally
    attempt cleaning up files until it goes below the target threshold (by
    default: {}).
    """.format(THRESHOLD, THRESHOLD_TARGET)

    parser.epilog = """
    Defaults should work fine. The defaults are set to be safe for
    systems with smaller disks. On larger ones the sum of the three
    rates (astlogs, backups and recordings) can be much closer to 1.
    For example: --backups-rate 0.7 . If your backups include recordings,
    change their rates accordingly. For example: -b 0.5 -r 0.2 .

    You can also run the script manually in the shell command line:
    /usr/share/ombutel/task-scripts/pbx_hd_clean
    """

    parser.add_argument("-t", "--threshold", action="store", type=float,
                        default=THRESHOLD,
                        help="Rate of used space at which we start cleaning. Default: %(default)s.")
    parser.add_argument("-T", "--threshold-target", action="store", type=float,
                        default=THRESHOLD_TARGET,
                        help="Rate of used space at which we stop cleaning. Default: %(default)s.")
    parser.add_argument("-a", "--astlogs-rate", action="store", type=float,
                        default=ALLOWED_USAGE['astlogs'],
                        help="Maximal part of free disk space used for Asterisk logs. Default: %(default)s")
    parser.add_argument("-b", "--backups-rate", action="store", type=float,
                        default=ALLOWED_USAGE['backups'],
                        help="Maximal part of free disk space used for backups. Default: %(default)s")
    parser.add_argument("-r", "--recordings-rate", action="store", type=float,
                        default=ALLOWED_USAGE['recordings'],
                        help="Maximal part of free disk space used for recordings. Default: %(default)s")
    parser.add_argument("-l", "--log-max-size", action="store", type=int,
                        default=LOG_FILE_MAX_SIZE,
                        help="Size (MB) beyond which log file may be deleted. Default: %(default)s (MB)")
    parser.add_argument("-n", "--backups-num", action="store", type=int,
                        default=BACKUPS_NUM,
                        help="Number of backups to always avoid delete. Default: %(default)s")
    parser.add_argument("--modules", action="store",
                        help="Comma-separates list of cleaner modules. Default: (all modules)")
    parser.add_argument("-v", "--verbose", action="store_true",
                        help="Be verbose")
    opt_sh = parser.add_argument_group("For Interactive Use")
    opt_sh.add_argument("-d", "--no-act", action="store_true",
                        help="Do nothing. Just print what is to be run")
    opt_sh.add_argument("-s", "--status", action="store_true",
                        help="Do nothing. Just show status")
    opt_sh.add_argument("--list", action="store_true",
                        help="Do nothing. Just list cleaner modules")
    opt_sh.add_argument("-e", "--no-email", action="store_true",
                        help="Do not send email notification")
    opt_sh.add_argument("--html-usage", action="store_true",
                        help="Do nothing. Format help as HTML (a single line)")
    opt_sh.add_argument("--html-usage-raw", action="store_true",
                        help="If html-usage: don't squash HTML to a single line")
    cfg = parser.parse_args(args)

    if cfg.html_usage:
        html_usage(parser, cfg)
        sys.exit(0)

    if cfg.backups_num <= 0:
        parser.error("E: Number of backups must be positive")
    for key in ['threshold', 'threshold_target', 'astlogs_rate',
                'backups_rate', 'recordings_rate']:
        if getattr(cfg, key) > 1:
            # Convert percents to rate
            setattr(cfg, key, getattr(cfg, key) / 100.0)
        val = getattr(cfg, key)
        if val >= 1 or val < 0:
            msg = "E: Invalid value for {}: must be between 0 and 1".format(key)
            parser.error(msg)

    sum_rates = cfg.astlogs_rate + cfg.backups_rate + cfg.recordings_rate
    if sum_rates > 1:
        msg = "E: sum of the three rates ({}, {}, {} is {} > 1". \
              format(cfg.astlogs_rate, cfg.backups_rate, cfg.recordings_rate,
                     sum_rates)
        parser.error(msg)

    if cfg.threshold <= cfg.threshold_target:
        msg = "E: Threshold should be higher than target threshold, however {} <= {}". \
              format(cfg.threshold, cfg.threshold_target)
        parser.error(msg)

    # Cannot be done earlier, because some of them use configuration
    # values. Cannot be done later, because we want to set list of
    # modules, and this requires knowing which ones are valid.
    CFG = cfg

    init_modules()
    # here we set modules
    if cfg.modules is None:
        return
    modules = cfg.modules.split(',')
    if not Cleaner.set_modules(modules):
        parser.error("E: invalid list of cleaner modules: " + cfg.modules)
    CFG = cfg


def init_modules():
    """ All items to consider, in the order to check """
    AsteriskCores()
    YumPackages()
    AptCache()
    AsteriskLogs()
    Backups()
    Recordings()


def show_status():
    """ Report status """
    ds = DiskSpace()
    print(ds)
    for candidate in Cleaner.modules():
        candidate.update_usage()
        print(candidate)

    return 0


def list_modules():
    """ List all cleaner modules """
    for candidate in Cleaner.modules():
        print("{}:\t{}".format(candidate.name, candidate.path))

    return 0


def html_usage(parser, cfg):
    """ Print usage text as an HTML snippet.

    If not html_usage_raw was not set in the configuation: produce a
    single line.
    """
    parser.formatter_class = HTMLFormatter
    help_text = parser.format_help()
    help_text = re.sub('<ul>:', '<ul>', help_text)
    if not cfg.html_usage_raw:
        help_text = re.sub('\n', '', help_text)
        help_text = re.sub(r'\s+', ' ', help_text)
    print(help_text)

def send_email(body):
    """ Sent email report of cleaned content """
    subprocess.call(['php', '/usr/share/ombutel/scripts/pbx_hd_send_email.php', json.dumps(body)])

def free_space():
    """ Run the actual cleaning """
    global DISK_SPACE

    ds = DiskSpace()
    DISK_SPACE = ds
    log_verbose("I: Usage: {}".format(ds))

    if ds.below_threshold():
        log_verbose("I: Enough free space. No need to clean.")
        return (0, [])
    body = {}
    usage = { "usage" : "{:.1%}".format(ds.usage_rate()),
        "threshold" : "{:.1%}".format(CFG.threshold),
        "target" : "{:.1%}".format(CFG.threshold_target) }

    for candidate in Cleaner.modules():
        candidate.update_usage()
        if candidate.is_usage_ok():
            log_verbose("I: Cleaner {}: Nothing to do.".format(candidate.name))
            continue
        log_verbose("I: Cleaner {}:".format(candidate.name))

        body[candidate.name] = candidate.clean()
        ds.update()
        if ds.below_threshold_target():
            log_verbose("I: Cleaner {}: Enough free space. Done ({:.1%}).".
                        format(candidate.name, ds.usage_rate()))
            usage["current"] = "{:.1%}".format(ds.usage_rate())
            body.update(usage)
            return (0, body)
        else:
            usage["current"] = "{:.1%}".format(ds.usage_rate())
            log_verbose("I: Cleaner {}: We still have {:.1%} used. Check next one.".
                        format(candidate.name, ds.usage_rate()))
    if not ds.below_threshold_target():
        log("E: Disk usage is still above the target threshold: {:.1%}".
            format(ds.usage_rate()))
        usage["current"] = "{:.1%}".format(ds.usage_rate())
        body.update({"stats" : usage})
        return (ERROR_FAILED_TO_CLEAR, body)

def main():
    """ Main function """
    parse_cmd_line()
    if CFG.status:
        return show_status()
    elif CFG.list:
        return list_modules()
    else:
        rc, body = free_space()
        if not CFG.no_act and not CFG.no_email and len(body) > 0:
            log_verbose("I: About to run send_email")
            send_email(body)
        return rc


if __name__ == '__main__':
    exit(main())

