Mini Shell

Direktori : /proc/self/root/opt/sharedrads/oldrads/
Upload File :
Current File : //proc/self/root/opt/sharedrads/oldrads/autosuspend.py

#!/opt/imh-python/bin/python3
"""Automatic resource overage suspension script"""
import datetime
import socket
import sys
import re
import sh
import time
import yaml
import os
import configparser
import pp_api
import logging
import pwd
from collections import defaultdict
from multiprocessing import cpu_count
from functools import partial
from rads.shared import (
    is_suspended,
    is_cpanel_user,
    get_secure_username,
    SYS_USERS,
)


class Autosuspend:
    """
    Gathers current and historic user cp usage data and determines
    whether or not to enact an account suspension, send a warning
    or pass over system users
    """

    config_files = [
        '/opt/sharedrads/etc/autosuspend.cfg',
        '/opt/sharedrads/etc/autosuspend.cfg.local',
    ]

    brand = ('imh', 'hub')['hub' in socket.gethostname()]

    def __init__(self):
        """
        Initializes an instance of Autosuspend, including a logging
        object, parsed config from Autosuspend.config_files and
        various information on system users
        """
        self.config = configparser.ConfigParser(allow_no_value=False)
        self.config.read(self.config_files)

        logging.basicConfig(
            level=logging.INFO,
            format=f'%(asctime)s {sys.argv[0]}: %(message)s',
            datefmt='%Y-%m-%d:%H:%M:%S %Z',
            filename=self.suspension_log,
        )
        self.logger = logging.getLogger('suspension_logger')

        self.priors = prior_events(
            data_file=self.data_file,
            log=self.suspension_log,
            log_offset=self.suspension_log_offset,
        )

        self.suspensions_enabled = self.config.getboolean(
            'suspensions',
            'enabled',
        )
        self.warnings_enabled = self.config.getboolean(
            'warnings',
            'enabled',
        )

        self.freepass_enabled = self.config.getboolean(
            'freepass',
            'enabled',
        )

        self.actions = {
            'suspension': enact_suspension,
            'warning': send_warning,
            'freepass': give_free_pass,
        }

        self.server_overloaded = server_overloaded()

        _users = top_users(
            interval_file=self.sa_interval_file,
            max_age=self.sa_interval_file_max_age,
        )

        self.users = {
            name: User(
                name=name,
                delta=delta,
                suspensions=self.priors.get(name, {}).get('suspensions', []),
                warnings=self.priors.get(name, {}).get('warnings', []),
                freepasses=self.priors.get(name, {}).get('freepasses', []),
            )
            for name, delta in _users
            if not is_suspended(name)
        }

    def __repr__(self):
        """
        Returns a representation of an Autosuspend object
        """
        repr_data = [
            'brand',
            'disruptive_action_interval',
            'server_load_factor',
            'server_overloaded',
            'suspensions_enabled',
            'warnings_enabled',
            'freepass_enabled',
        ]
        repr_str = '<Autosuspend {}>'.format(
            ' '.join([f'{i}:{getattr(self, i)}' for i in repr_data])
        )
        return repr_str

    def suspension_critera_met(self, user):
        """
        Tests a User object to see if it meets suspension criteria
        """
        if not user.warned_within(self.disruptive_action_interval):
            self.logger.debug(
                f'{user.name} not warned within {self.disruptive_action_interval}, not suspending'
            )
            return False

        # double check this logic - if user was suspended longer ago than action_interval they should be elligible
        if not user.suspended_longer_ago(self.disruptive_action_interval):
            self.logger.debug(
                f'{user.name} not suspended within {self.disruptive_action_interval}, not suspending'
            )
            return False

        if user.num_warnings <= self.warning_count:
            self.logger.debug(
                f'Not suspended; only {user.num_warnings} warnings, need {self.warning_count}'
            )
            return False

        if float(user.delta) >= float(self.suspensions['max_delta']):
            return True

        return False

    def warning_critera_met(self, user):
        """
        Tests a User object to see if it meets warning criteria
        """

        if user.warned_within(self.disruptive_action_interval):
            self.logger.debug(
                f'{user.name} warned within {self.disruptive_action_interval}, not warning'
            )
            return False

        if float(user.delta) >= float(self.warnings['max_delta']):
            return True
        else:
            self.logger.debug(
                '{} has not consumed more than {}cp in the last {}'.format(
                    user.name,
                    self.warnings['max_delta'],
                    self.disruptive_action_interval,
                )
            )

        return False

    def freepass_criteria_met(self, user):
        """
        Tests a user to see if it meets freepass criteria
        """
        self.logger.debug(f'Testing {user.name} for freepass...')
        if float(user.delta) >= float(self.freepass['max_delta']):
            self.logger.debug(
                f'{user.name} has a delta of {user.delta}, which is above the threshold.'
            )
            if not user.given_freepass_within(self.time_between_free_passes):
                self.logger.debug(
                    f'{user.name} was not given a free pass within {self.time_between_free_passes} so they get one'
                )
                return True
            else:
                self.logger.debug(
                    f'{user.name} got a free pass within the last {self.time_between_free_passes} days, not sending another'
                )

        return False

    def run(self):
        """
        Loops through Autosuspend.users, calling Autosuspend.test for each

        """
        self.logger.info(f'Autosuspend run starting {repr(self)}')
        if not self.users:
            return

        for user in self.users.values():
            action = self.test(user)
            action_func = self.actions.get(
                action,
                lambda *x, **y: None,
            )
            wrapper = partial(
                action_func, email_template=getattr(self, f'{action}_template')
            )

            wrapper(user=user.name, comment=user.note)
        self.logger.info('Autosuspend run complete')

    def test(self, user):
        """
        Determines what action, if any to take against an individual
        User object
        """
        user.suspend = self.suspension_critera_met(user)
        user.warn = self.warning_critera_met(user)
        user.freepass = self.freepass_criteria_met(user)

        if user.suspend and self.suspensions_enabled and self.server_overloaded:
            user.note = (
                'AUTO SUSPENSION: Consumed {:.2f}cp within '
                'the last measured interval.'.format(user.delta)
            )
            self.logger.debug(f'Suspending {user}')
            self.logger.info(
                f'{user.delta} [AUTO_SUSPENSION] ra - root "{user.note}"'
            )
            return 'suspension'

        elif user.freepass and self.freepass_enabled:
            user.note = (
                'AUTO RA FREEPASS: Consumed {:.2f}cp within '
                'the last measured interval.'.format(user.delta)
            )
            self.logger.debug(f'Freepassing {user}')
            self.logger.info(f'{user.name} [FREEPASS] ra - root "{user.note}"')
            return 'freepass'

        elif user.warn and self.warnings_enabled:
            user.note = (
                'AUTO RA WARNING: Consumed {:.2f}cp within '
                'the last measured interval.'.format(user.delta)
            )
            self.logger.debug(f'Warning {user}')
            self.logger.info(f'{user.name} [WARNING] ra - root "{user.note}"')
            return 'warning'
        else:
            self.logger.debug(f'Skipping {user}')
            return 'skip'

    def __getattr__(self, item):
        """
        Returns items as strings from settings and brand-specific settings
        sections or entire config sections as a dict

        e.g. <Autosuspend instance>.suspension_log;
        <Autosuspend instance>.settings['suspension_log']
        """
        if item in self.config.sections():
            return dict(self.config.items(item))
        # See if a given key is present in the settings section
        for section in (f'settings_{self.brand}', 'settings'):
            if self.config.has_option(section, item):
                return self.config.get(section, item)


class User:
    """
    Instantiated to represent a system user.
    """

    def __init__(self, **args):
        """
        Initializes the User object
        """
        self.data_dict = args
        self.warn = False
        self.suspend = False
        self.freepass = False
        self.num_suspensions = len(args['suspensions'])
        self.num_warnings = len(args['warnings'])
        self.num_freepasses = len(args['freepasses'])

    def __getattr__(self, item):
        """
        Returns an item from self.data_dict or None in the event of a KeyError
        """
        try:
            return self.data_dict[item]
        except KeyError:
            pass

    def __repr__(self):
        """
        Returns a useful representation of a User object
        """
        repr_data = [
            'name',
            'delta',
            'last_suspension',
            'last_warning',
            'last_freepass',
            'num_suspensions',
            'num_warnings',
            'num_freepasses',
            'suspend',
            'warn',
            'freepass',
            'note',
        ]

        repr_str = '<User {}>'.format(
            ' '.join([f'{i}:{getattr(self, i)}' for i in repr_data])
        )
        return repr_str

    def warned_within(self, delta):
        """
        Returns True if the user's last warning was sent within the current
        time - delta, False otherwise
        """
        if not isinstance(delta, datetime.timedelta):
            delta = str_to_timedelta(delta)
        try:
            return datetime.datetime.now() < (self.last_warning + delta)
        except TypeError:
            return False

    def suspended_longer_ago(self, delta):
        """
        Returns True if the user's last suspension was longer ago
        than the current time - delta, False otherwise
        """
        if not isinstance(delta, datetime.timedelta):
            delta = str_to_timedelta(delta)
        try:
            return datetime.datetime.now() > (self.last_suspension + delta)
        except TypeError:
            return True

    def given_freepass_within(self, delta):
        """
        In the case self.last_freepass is None, we return false.
        """
        if not self.last_freepass:
            return False
        if not isinstance(delta, datetime.timedelta):
            delta = str_to_timedelta(delta)
        try:
            return datetime.datetime.now() < (self.last_freepass + delta)
        except TypeError:
            return True

    @property
    def last_suspension(self):
        """
        returns a datetime object which represents the last time
        the user was suspended or None
        """
        return self._last_suspension

    @last_suspension.getter
    def last_suspension(self):
        """
        returns a datetime object which represents the last time
        the user was suspended or None
        """
        return self._nth_date('suspensions', -1)

    @property
    def last_warning(self):
        """
        returns a datetime object which represents the last time
        the user was warned or None
        """
        return self._last_warning

    @last_warning.getter
    def last_warning(self):
        """
        returns a datetime object which represents the last time
        the user was warned or None
        """
        return self._nth_date('warnings', -1)

    @property
    def last_freepass(self):
        return self._last_freepass

    @last_freepass.getter
    def last_freepass(self):
        return self._nth_date('freepasses', -1)

    def _nth_date(self, attr, index):
        """
        Return a datetime object representation of a date from
        suspension or warning lists
        """
        items = getattr(self, attr)
        try:
            return datetime.datetime.fromtimestamp(
                sorted(map(float, items))[index]
            )
        except (TypeError, IndexError):
            pass


def str_to_timedelta(time_str):
    """
    Munges strings into timedelta objects
    """
    match = re.search(
        r"""(:?
        (:?(?P<hours>\d+)[Hh])?
        (:?(?P<minutes>\d+)[Mm])?
        (:?(?P<days>\d+)[Dd])?
        (:?(?P<seconds>\d+)[Ss])?
        )+""",
        ''.join(time_str.split()),
        re.VERBOSE,
    )
    groups = {k: float(v) for k, v in match.groupdict().items() if v}
    return datetime.timedelta(**groups)


def server_overloaded(factor=1.5):
    """
    Determines whether or not the sever is unduly stressed by comparing the
    15-minute load average and the product of number of cores and 'factor'.
    """
    return (cpu_count() * factor) <= os.getloadavg()[-1]


def try_open_yaml(yaml_path):
    """Try to read a yaml file. If impossible, return an empty dict"""
    try:
        data = yaml.load(file(yaml_path, 'r'))
    except (OSError, yaml.error.MarkedYAMLError):
        return {}
    if not isinstance(data, dict):
        return {}
    return data


def get_log_data(logfile, offsetfile, ignore_offset=False):
    """
    Reads and offset from the offset file, returns data from the offset to
    the end of the file
    """

    # try to read the offset from the offset file
    try:
        with open(offsetfile) as offset_fh:
            offset = int(offset_fh.readline())
    # Set offset to 0 if the offset can't be converted to an integer or the
    # file is missing
    except (OSError, ValueError):
        offset = 0

    if ignore_offset:
        offset = 0

    try:
        with open(logfile) as logfile_fh:
            logfile_fh.seek(0, 2)
            logfile_length = logfile_fh.tell()
            if offset > logfile_length:
                logfile_fh.seek(0)
            else:
                logfile_fh.seek(offset)
            output = logfile_fh.readlines()
            newoffset = logfile_fh.tell()
    # If the file can't be opened return an empty string
    # and set newoffset to 0
    except OSError:
        output = ""
        newoffset = 0

    # Write the new offset to the offset file
    with open(offsetfile, 'w') as offset_fh:
        offset_fh.write(str(newoffset))
    return output


def prior_events(log=None, log_offset=None, data_file=None):
    '''Returns a dict that contains account suspension times'''
    suspension_re = re.compile(
        r"""(?P<time>\d{4}-\d{2}-\d{2}:\d{2}:\d{2}:\d{2})
        \s\w+\s+[\w/\.-]+:\s+(?P<user>\w+)\s+\[
        (:?
        (?P<suspensions>(:?AUTO_)?SUSPENSION)|
        (?P<warnings>WARNING)|
        (?P<freepasses>FREEPASS)
        )
        \]\s+ra""",
        re.VERBOSE,
    )
    priors = defaultdict(
        lambda: {'suspensions': [], 'warnings': [], 'freepasses': []}
    )

    # Get prior suspensions
    if data_file is not None:
        priors.update(try_open_yaml(data_file))

    # If past_suspensions wasn't populated with any data, skip using the offset
    # when reading log data
    if len(priors) < 1:
        skip_offset = True
    else:
        skip_offset = False

    # Lolast_freepassop through new lines from the suspension log and add to the suspension
    # data dict
    for line in get_log_data(log, log_offset, skip_offset):
        match = suspension_re.search(line)
        if match:
            user = match.group('user')
            event_time = int(
                time.mktime(
                    time.strptime(match.group('time'), "%Y-%m-%d:%H:%M:%S")
                )
            )
            for event in ('suspensions', 'warnings', 'freepasses'):
                if match.group(event):
                    try:
                        priors[user][event].append(event_time)
                    except (KeyError, AttributeError):
                        priors[user][event] = [event_time]

    yaml.dump(dict(list(priors.items())), file(data_file, 'w+'))

    return priors


def prepare_str_content(user):
    """Runs various commands, returns string output"""
    output = []

    rads_cmds = (
        ('/opt/sharedrads/check_user', dict(plaintext=True)),
        (
            '/opt/sharedrads/nlp',
            dict(w=80, p=True, _err_to_out=True, today=True),
        ),
        ('/opt/sharedrads/recent-cp', dict(b=True)),
    )

    for script, kwargs in rads_cmds:
        # these RADS scripts all accept a user name as the first
        # positional argument so it can just be baked in
        try:
            cmd = sh.Command(script).bake(user)
        except sh.CommandNotFound:
            continue

        try:
            result = cmd(**kwargs)
        except sh.ErrorReturnCode:
            continue

        output.append(
            (
                f'>>> {result.ran}',
                result.stdout,
            )
        )

    output.append(
        (
            '>>> Running processes prior to suspension',
            sh.awk(sh.ps('auwwx', _piped=True), "$1 ~ /%s|USER/" % user).stdout,
        )
    )

    output = "\n\n".join(['\n'.join(items) for items in output])
    return output


def save_str(user, content):
    """
    Saves STR data in the user's '.imh' folder
    """
    imhdir = os.path.join(pwd.getpwnam(user).pw_dir, '.imh')
    if not os.path.isdir(imhdir):
        try:
            os.mkdir(imhdir)
        except OSError:
            return

    date_string = datetime.datetime.strftime(
        datetime.datetime.now(), '%Y-%m-%d_%H:%M:%S'
    )

    strfile = os.path.join(imhdir, f'str_{date_string}')

    with open(strfile, 'w') as str_fh:
        str_fh.write(content)


def send_str(user, content):
    """Sends an STR to the STR queue"""
    strmailer_cmd = sh.Command('/opt/sharedrads/mailers/strmailer-suspend.py')
    strmailer_cmd(user, socket.gethostname().split('.')[0], _in=content)


def enact_suspension(user, comment, email_template):
    """Suspends the user, notes the account and sends an STR"""
    ppa = pp_api.PowerPanel()

    ppa.call('notification.send', template=email_template, cpanelUser=user)
    ppa.call(
        'hosting.insert-note',
        admin_user='auto',
        user=user,
        note='AUTO SUSPENSION: %s' % comment,
        flagged=True,
        type='Suspension',
    )

    message = 'RADS suspended {} for resource abuse.\n\n{}\n\n' '{}'.format(
        user, comment, prepare_str_content(user)
    )

    send_str(user, message)
    save_str(user, message)

    suspend_cmd = sh.Command('/opt/sharedrads/suspend_user')
    suspend_cmd(
        user, invoked_by=sys.argv[0], ra=True, c=comment, _tty_out=False
    )


def send_warning(user, comment, email_template):
    """Sends a warning to the user, notes the customer's account"""
    ppa = pp_api.PowerPanel()
    cmd = sh.Command(
        '/opt/sharedrads/python/send_customer_str/send_customer_str'
    )
    run = cmd('-u', user)
    if run.exit_code != 0:
        send_str(
            user, "Could not generate STR for customer with send_customer_str"
        )
        ppa.call('notification.send', template=email_template, cpanelUser=user)

    ppa.call(
        'hosting.insert-note',
        admin_user='auto',
        user=user,
        note='AUTO RA NOTICE: %s' % comment,
        flagged=True,
        type='Resource Management',
    )

    save_str(user, prepare_str_content(user))


def give_free_pass(user, comment, email_template):
    save_str(user, prepare_str_content(user))


def top_users(interval_file, max_age, num_users=None):
    """
    Return sorted top users in the last time interval. If num_users specified,
    only return that number of users. Otherwise, return all of them. This
    function also handles saving the current sa data for the next run
    """
    this_sa = get_sa_dict()
    user_deltas = []
    if check_interval_file_age(interval_file, max_age):
        last_sa = try_open_yaml(interval_file)

        for user in this_sa.keys():
            try:
                delta = float(this_sa[user]) - float(last_sa[user])
            except (KeyError, ValueError):
                continue
            user_deltas.append((user, delta))

        # sort by usage, descending
        user_deltas.sort(key=lambda tup: tup[1], reverse=True)

    # save this run's sa data for the next run
    with open(interval_file, 'w') as data_file:
        yaml.dump(
            this_sa,
            data_file,
        )
    return user_deltas[:num_users]


def get_sa_dict():
    """get info from 'sa -m' in the form of a dict"""
    sec_usr = get_secure_username()
    skip_user_re = re.compile(
        r'(?:{}{})\b'.format(
            '' if sec_usr is None else '%s|' % sec_usr, '|'.join(SYS_USERS)
        )
    )
    sa_re = re.compile(
        r'^(?P<user>\w+) +[0-9]+ +[0-9]+\.[0-9]{2}'
        r're +(?P<cp>[0-9]+\.[0-9]{2})cp '
    )
    sa_output = [
        x for x in sh.sa(m=True).splitlines() if not skip_user_re.match(x)
    ]
    sa_dict = {}
    for line in sa_output:
        match = sa_re.search(line)
        if match is not None:
            user, cp_total = match.group('user'), match.group('cp')
            if not is_cpanel_user(user):
                continue
            sa_dict[user] = float(cp_total)
    return sa_dict


def check_interval_file_age(interval_file_path, max_age):
    """
    Check how old the sa_interval_file is. Return true if the age is what
    is expected from autosuspend.cfg. If false, the file's contents cannot
    be used to decide whether users should be suspended
    """
    if not isinstance(max_age, datetime.timedelta):
        max_age = str_to_timedelta(max_age)
    try:
        mtime = datetime.datetime.fromtimestamp(
            os.path.getmtime(interval_file_path)
        )
    except OSError:  # file not found
        return False
    return (datetime.datetime.now() - mtime) < max_age


if __name__ == '__main__':
    Autosuspend().run()

Zerion Mini Shell 1.0