#!/usr/bin/env python3
# This installer sets up the required jobe users (i.e. the "users"
# that run submitted jobs) and compiles and adjusts the runguard sandbox.
# It must be run as root.

# If run with the parameter --purge, all files and users set up by a previous
# run of the script are deleted at the start.

import os
import subprocess
import re
import sys
import argparse

class LoginDefsException (Exception):
    pass


JOBE_DIRS = ['/var/www/jobe', '/var/www/html/jobe']
LANGUAGE_CACHE_FILE = '/tmp/jobe_language_cache_file'
FILE_CACHE_BASE = '/home/jobe/files'

def get_config(param_name, install_dir):
    '''Get a config parameter from <<install_dir>>/app/Config/Jobe.php.
       An exception occurs if either the file or the required parameter
       is not found.
    '''
    with open(f'{install_dir}/app/Config/Jobe.php') as config:
        lines = config.readlines()
    dollar_param = r'\$' + param_name  # PHP variable name for config parameter
    patterns = [
        rf"\s*public\s+(string)\s+{dollar_param}\s*=\s*\"([^\"]+)\";.*\n",      # Double-quoted string
        rf"\s*public\s+(string)\s+{dollar_param}\s*=\s*'([^']+)';.*\n",         # Single-quoted string
        rf"\s*public\s+(int|bool|float)\s+{dollar_param}\s*=\s*([^;]+);.*\n"    # Non-string literal
    ]
    for line in lines:
        for pattern in patterns:
            match = re.match(pattern, line)
            if match:
                return match.group(2)
    raise Exception('Config param ' + param_name + ' not found')


def fail():
    """We're dead, Fred"""
    print("Install failed")
    sys.exit(1);


def get_webserver():
    '''Find the user name used to run the Apache or nginx web server'''
    ps_cmd = "ps aux | grep -E '/usr/sbin/(apache2|httpd)|nginx: worker'"
    try:
        ps_lines = subprocess.check_output(ps_cmd, shell=True).decode('utf8').split('\n')
    except subprocess.CalledProcessError:
        raise Exception("ps command to find web-server user id failed. Is the web server running?")
    names = {ps_line.split(' ')[0] for ps_line in ps_lines}
    candidates = names.intersection(set(['apache', 'www-data', 'nginx']))
    if len(candidates) != 1:
        raise Exception("Couldn't determine web-server user id. Is the web server running?")
    webserver_user = list(candidates)[0]
    print("Web server is", webserver_user)
    return webserver_user


def do_command(cmd, ignore_errors=False):
    '''Execute the given OS command user subprocess.call.
       Raise an exception on failure unless ignore_errors is True.
    '''
    try:
        returncode = subprocess.call(cmd, shell=True)
    except subprocess.SubprocessError:
        returncode = -1
    if returncode != 0:
        if ignore_errors:
            print("Command '{}' failed. Ignoring.".format(cmd))
        else:
            raise OSError("Command ({}) failed".format(cmd))


def check_php_version():
    '''Check that the installed PHP version is at least 7.4.
       Raise an exception on failure.
    '''
    if subprocess.call("php -r 'exit(version_compare(PHP_VERSION, \"7.4.0\", \"lt\"));'", shell=True) != 0:
            raise OSError("Jobe requires at least PHP 7.4.")


def make_sudoers(install_dir, webserver_user, num_jobe_users):
    '''Build a custom jobe-sudoers file for include in /etc/sudoers.d.
       It allows the webserver to run the runguard program as root and also to
       kill any jobe tasks and delete the run directories.
    '''
    commands = [install_dir + '/runguard/runguard']
    commands.append('/bin/rm -R /home/jobe/runs/*')
    for i in range(num_jobe_users):
        commands.append('/usr/bin/pkill -9 -u jobe{:02d}'.format(i))
        for directory in get_config('clean_up_path', install_dir).split(';'):
            commands.append('/usr/bin/find {}/ -user jobe{:02d} -delete'.format(directory, i))

    sudoers_file_name = '/etc/sudoers.d/jobe-sudoers'
    with open(sudoers_file_name, 'w') as sudoers:
        os.chmod(sudoers_file_name, 0o440)
        for cmd in commands:
            sudoers.write('{} ALL=(root) NOPASSWD: {}\n'.format(webserver_user, cmd))


def make_user(username, comment, make_home_dir=False, group='jobe', uid=None):
    ''' Check if user exists. If not, add the named user with the given comment.
        Make a home directory only if make_home_dir is true.
    '''
    try:
        do_command('id ' + username + '> /dev/null 2>&1')
        print(username, 'already exists')
    except:
        opt = '--home /home/jobe -m' if make_home_dir else ' -M'
        group_opt = '' if group is None else f" -g {group}"
        uid_opt = '' if uid is None else f" -u {uid}"
        do_command(f'useradd -r {opt} -s "/bin/false"{group_opt}{uid_opt} -c "{comment}" {username}')


def make_directory(dirpath, owner, group, permissions=771):
    '''If dirpath doesn't exist, make a directory and give it
       the given owner, group and permissions'''
    if not os.path.exists(dirpath):
        os.makedirs(dirpath)
    do_command('chown {0}:{1} {2}; chmod {3} {2}'.format(owner, group, dirpath, permissions))


def make_runguard(install_dir):
    """Make the runguard application"""
    runguard_commands = [
        "cd {0}".format(install_dir + '/runguard'),
        "gcc -o runguard runguard.c -lm",
        "chmod 700 runguard"
    ]
    cmd = ';'.join(runguard_commands)
    try:
        do_command(cmd)
    except OSError as e:
        print("Compile of runguard failed.")
        print("Check all dependencies are installed, particularly libcgroup-dev")
        raise OSError("Runguard build failed")


def do_purge(install_dir):
    '''Purge existing users and extra files set up by a previous install'''
    with open('/etc/passwd') as infile:
        lines = infile.read().splitlines()

    jobe_users = [line.split(':')[0] for line in lines if re.match(r'jobe\d\d', line)]
    for jobe_user in jobe_users:
        do_command(f'userdel {jobe_user}', ignore_errors=True)

    do_command('userdel jobe', ignore_errors=True)
    #do_command('groupdel jobe', ignore_errors=True)
    do_command('rm -rf /home/jobe')
    do_command('rm -rf /var/log/jobe')
    do_command('rm -rf {}/files'.format(install_dir), ignore_errors=True)
    do_command('rm -rf ' + FILE_CACHE_BASE + '/*', ignore_errors=True)


def update_runguard_config(install_dir, num_jobe_users):
    # Make sure the number of valid users are matching our num jobe users
    print("Setting up runguard config")
    runguard_users = ["domjudge", "jobe"] + ['jobe{:02d}'.format(i) for i in range(num_jobe_users)]
    with open(os.path.join(install_dir, "runguard/runguard-config.h"), "r") as runguard_config_in:
        runguard_config = runguard_config_in.read()

    runguard_valid_users = ",".join(runguard_users)

    runguard_config = re.sub(r"#define\s+VALID_USERS[^\n]+", '#define VALID_USERS "' + runguard_valid_users + '"', runguard_config)
    with open(os.path.join(install_dir, "runguard/runguard-config.h"), "w") as runguard_config_out:
        runguard_config_out.write(runguard_config)


def make_workers(num_jobe_users, max_uid):
    """Make the Jobe worker users"""
    print(f"Making {num_jobe_users} Jobe workers")
    uid = None if max_uid is None else max_uid - 1 # First uid is one less than max_uid (used by Jobe)
    for i in range(num_jobe_users):
        username = 'jobe{:02d}'.format(i)
        make_user(username, 'Jobe server task runner', uid=uid)
        if uid is not None:
            uid -= 1


def create_jobe_user_and_files(install_dir, webserver_user, max_uid):
    """Create the user jobe and set up the home directory for runs"""
    print("Making user jobe")
    make_user('jobe', 'Jobe user. Provides home for runs and files.', True, None, max_uid)
    # make sure webuser can reach /home/jobe/runs despite its not being in jobe group
    do_command("chmod 751 /home/jobe")
    print("Setting up Jobe runs directory (/home/jobe/runs)")
    make_directory('/home/jobe/runs', 'jobe', webserver_user)
    print("Setting up Jobe files directory (/home/jobe/files)")
    make_directory('/home/jobe/files', 'jobe', webserver_user)
    print("Setting up Jobe log directory (/var/log/jobe)")
    make_directory('/var/log/jobe', 'jobe', webserver_user)


def process_command_line_args():
    """Command line argument handler."""
    def max_uid(s):
        max_uid = int(s)
        if max_uid < 200 or max_uid > 998:
            raise argparse.ArgumentTypeError("max_uid must be in the range 200 to 998")
        return max_uid

    parser = argparse.ArgumentParser(description='Jobe installer')
    parser.add_argument('--purge', action="store_true", help='Purge prior Jobe settings and files before installing')
    parser.add_argument('--uninstall', action="store_true", help='Uninstall jobe')
    parser.add_argument('--max_uid', action="store", type=max_uid, default=None,
                        help="Set the maximum system UID to be used by Jobe and its workers. For use in containers only.")
    args = parser.parse_args(sys.argv[1:])
    return args


def main():
    """Every home should have one."""

    args = process_command_line_args()
    install_dir = os.getcwd()
    if install_dir not in JOBE_DIRS:
        print("WARNING: Jobe appears not to have been installed in /var/www")
        print ("or /var/www/html as expected. Things might turn ugly.")

    if subprocess.check_output('whoami', shell=True) != b'root\n':
            print("****This script must be run by root*****")
            fail()

    try:
        if args.purge or args.uninstall:
            print('Purging all prior jobe users and files')
            do_purge(install_dir)
        if args.uninstall:
            print("Jobe has been uninstalled")
            sys.exit(0)

        check_php_version()
        webserver_user = get_webserver()

        # Ensure install directory subtree owned by webuser and not readable by others
        do_command('chown -R {0}:{0} {1}'.format(webserver_user, install_dir))
        do_command('chmod -R o-rwx,g+w {}'.format(install_dir))

        num_jobe_users = int(get_config('jobe_max_users', install_dir))
        print("num jobe users = ", num_jobe_users)
        create_jobe_user_and_files(install_dir, webserver_user, args.max_uid)
        make_workers(num_jobe_users, args.max_uid)

        print("Setting up file cache")
        make_directory(FILE_CACHE_BASE, 'jobe', webserver_user)

        print("Building runguard")
        update_runguard_config(install_dir, num_jobe_users)
        make_runguard(install_dir)

        make_sudoers(install_dir, webserver_user, num_jobe_users)
        try:
            os.remove(LANGUAGE_CACHE_FILE)
        except:
            pass

        print("Jobe installation complete")

    except Exception as e:
        print("Exception during install: " + str(e))
        fail()

main()
