#!/usr/bin/python3
import argparse, json, logging, os, re, shutil, signal, subprocess
import xml.etree.ElementTree as ET
from stat import *
from subprocess import PIPE
from xml.dom import minidom
import common

def run_program(args, fail_reason = ""):
    logging.debug('run: ' + str(args))
    result = subprocess.run(args, stdout=PIPE, stderr=PIPE)
    if fail_reason != "" and result.returncode == 0:
        common.fatal_error('Expected: ' + args[0] + ' to fail: ' + fail_reason)
    if fail_reason == "" and result.returncode != 0:
        common.fatal_error('Error in running: ' + args[0] + ' errors: ' + result.stdout.decode('utf-8') + ' ' + result.stderr.decode('utf-8'))
    return result.stdout.decode('utf-8')

def run_shell_program(cmd, fail_reason = ""):
    logging.debug('run: ' + cmd)
    result = subprocess.run(cmd, shell=True, stdout=PIPE, stderr=PIPE)
    if fail_reason != "" and result.returncode == 0:
        common.fatal_error('Expected: ' + cmd + ' to fail: ' + fail_reason)
    if fail_reason == "" and result.returncode != 0:
        common.fatal_error('Error in running: ' + cmd + ' errors: ' + result.stdout.decode('utf-8') + ' ' + result.stderr.decode('utf-8'))
    return result.stdout.decode('utf-8')

def systemctl_daemon_reload():
    logging.debug("Do daemon-reload")
    run_program(['systemctl', 'daemon-reload'])

def validate_environment(args):
    if not args.namespace_only and not args.disable and not os.path.exists('/sys/fs/cgroup/cgroup.controllers'):
        common.fatal_error("cgroups is not v2 on this machine")
    if os.getuid() != 0:
        common.fatal_error("this program must be run as root")
    if not args.namespace_only and not args.disable and not os.path.isfile('/etc/systemd/system.control/user.slice.d/50-IOAccounting.conf'):
        logging.debug('Activate accounting')
        run_program(['systemctl', 'set-property', 'user.slice', 'IOAccounting=yes', 'MemoryAccounting=yes', 'TasksAccounting=yes'])
        systemctl_daemon_reload()
    
def init_pgm():
    common.init_logging()
    parser = argparse.ArgumentParser(prog="setup",
                                     description='LiteSpeed Containers Setup Program')
    parser.add_argument('-l', '--log', type=int, help='set logging level, 10=Debug, 20=Info, 30=Warning, 40=Error.  Default is Info')
    parser.add_argument('-q', '--quiet', action='store_true', help='turns off all logging and only outputs what is requested.')
    parser.add_argument('-s', '--server_root', default=common.server_root(), help='the LiteSpeed SERVER_ROOT')
    parser.add_argument('-c', '--cgroups', type=int, default=2, help='The minimum value for cgroups')
    parser.add_argument('-i', '--cgroups-init', type=int, default=2, help='The cgroups value if not set currently')
    parser.add_argument('-n', '--namespace', type=int, default=2, help='The minimum value for namespace')
    parser.add_argument('-m', '--namespace-init', type=int, default=2, help='The namespace value if not set currently')
    parser.add_argument('-g', '--no-config', action='store_true', help='Skips checking of LiteSpeed Config')
    parser.add_argument('-t', '--no-subtree_control', action='store_true', help='Skips checking of system cgroup.subtree_control file')
    parser.add_argument('-u', '--no-upgrade', action='store_true', help='Does not check the version of LiteSpeed')
    parser.add_argument('-r', '--revert-config', action='store_true', help='Reverts modified LiteSpeed config file')
    parser.add_argument('-o', '--namespace-only', action='store_true', help='No cgroups, namespaces only')
    parser.add_argument('-d', '--disable', action='store_true', help='LiteSpeed Containers Disable')
    args = parser.parse_args()
    if not args.quiet or args.log != None:
        if args.log != None:
            logging.getLogger().setLevel(args.log)
        else:
            logging.getLogger().setLevel(logging.INFO)
        logging.debug("Entering setup")
    common.set_server_root(args.server_root)
    validate_environment(args)
    return args

def config_filename(lsws):
    if lsws:
        return common.server_root() + "/conf/httpd_config.xml"
    return common.server_root() + "/conf/httpd_config.conf"

def config_filename_revert(lsws):
    return config_filename(lsws) + '_lssetup'

def is_lsws():
    if (os.path.isfile(config_filename(True))):
        return True
    if (os.path.isfile(config_filename(False))):
        return False
    common.fatal_error("Can not identify the LiteSpeed system type")

def copy_file_details(source, dest):
    s = os.stat(source)
    shutil.copystat(source, dest)
    os.chown(dest, s.st_uid, s.st_gid)

def set_cgroups(root, disable, val, init):
    valSet = False
    findLimit = root.find('./security/CGIRLimit')
    if findLimit == None:
        common.fatal_error('Unexpected missing element CGIRLimit in configuration file')
    cgroups = findLimit.find('cgroups')
    if disable:
        if cgroups != None and int(cgroups.text) > 0:
            logging.debug('cgroups needs disabling')
            cgroups.text = str(0)
            valset = True
    elif cgroups == None:
        logging.debug('cgroups not in LSWS file')
        cgroups = ET.SubElement(findLimit, 'cgroups')
        cgroups.text = str(init)
        valSet = True
    elif disable and int(cgroups.text) > 0:
        logging.debug('cgroups needs disabling')
        cgroups.text = str(0)
        valset = True
    elif int(cgroups.text) < val:
        logging.debug('cgroups bad value in LSWS file')
        cgroups.text = str(val)
        valSet = True
    if valSet:
        logging.debug('cgroups set')
    else:
        logging.debug('cgroups ok')
        
    return valSet    

def set_namespace(root, disable, val, init):
    valSet = False
    findSecurity = root.find('./security')
    if findSecurity == None:
        common.fatal_error('Unexpected missing element Security in configuration file')
    namespace = findSecurity.find('namespace')
    if disable:
        if namespace != None and int(namespace.text) > 0:
            logging.debug('namespace needs disabling')
            namespace.text = str(0)
            valSet = True
    elif namespace == None:
        logging.debug('namespace not in LSWS file')
        namespace = ET.SubElement(findSecurity, 'namespace')
        namespace.text = str(init)
        valSet = True
    elif int(namespace.text) < val:
        logging.debug('namespace bad value in LSWS file')
        namespace.text = str(val)
        valSet = True
    if valSet:
        logging.debug('namespace set')
    else:
        logging.debug('namespace ok')
        
    return valSet    

def lsws_restart():
    pid = run_shell_program("ps -ef|grep '[l]shttpd - main' | awk -F ' ' '{print $2}'")
    if pid == '':
        logging.debug("LiteSpeed not running")
        return
    try:
        os.kill(int(pid), signal.SIGUSR1)
    except OSError as err:
        common.fatal_error("Error sending graceful restart to ListSpeed: %s" % err)
    logging.debug("LiteSpeed restarted")
    
def lsws_config(args):
    file = config_filename(True)
    try:
        tree = ET.parse(file)
    except Exception as err:
        common.fatal_error('Error parsing configuration: %s' % err)
    revert = config_filename_revert(True)
    shutil.copy2(file, revert)
    copy_file_details(file, revert)
    if not args.namespace_only:
        valSet = set_cgroups(tree.getroot(), args.disable, args.cgroups, args.cgroups_init)
    if set_namespace(tree.getroot(), args.disable, args.namespace, args.namespace_init):
        valSet = True
    if valSet:
        try:
            #ET.indent(tree, space='  ')
            tree.write(file, encoding="UTF-8", xml_declaration=True, short_empty_elements=False)
        except Exception as err:
            common.fatal_error('Error updating configuration: %s' % err)
        lsws_restart()
            
def lsws_validate(args):
    lsws_config(args)
        
def rewrite_ols_config(args, cgroupsAt, pastCGIRLimit, cgroupsVal, namespaceAt, lineNo, namespaceVal):
    stop1 = 0
    replace1 = False
    if not args.namespace_only:
        if cgroupsAt != 0:
            if (args.disable and int(cgroupsVal) > 0) or (not args.disable and int(cgroupsVal) <= args.cgroups):
                stop1 = cgroupsAt
                replace1 = True
                if args.disable:
                    setCgroups = 0
                else:
                    setCgroups = args.cgroups
        elif not args.disable:
            stop1 = pastCGIRLimit
            setCgroups = args.cgroups_init
    stop2 = 0
    replace2 = False
    if namespaceAt != 0:
        if (args.disable and int(namespaceVal) > 0) or (not args.disable and int(namespaceVal) <= args.namespace):
            stop2 = namespaceAt
            replace2 = True
            if args.disable:
                setNS = 0
            else:
                setNS = args.namespace
    elif not args.disable:
        stop2 = lineNo
        setNS = args.namespace_init
    lineNo = 0
    file = config_filename(False)
    try:
        f = open(file, 'r')
    except Exception as err:
        common.fatal_error('Error opening %s: %s' % (file, err))
    fileout = common.server_root() + "/conf/httpd_config.out"
    try:
        w = open(fileout, 'w')
    except Exception as err:
        common.fatal_error('Error opening %s: %s' % (fileout, err))
    try:
        for line in f:
            replace = False
            if lineNo > 0:
                if stop1 != 0 and lineNo == stop1:
                    w.write('  cgroups %d\n' % setCgroups)
                    replace = replace1
                    logging.debug('writing cgroups %d' % setCgroups)
                elif lineNo == stop2:
                    w.write('namespace %d\n'% setNS)
                    replace = replace2
                    logging.debug('writing namespace %d' % setNS)
            if not replace:
                w.write(line)
            lineNo += 1
        f.close()
        w.close()
        copy_file_details(file, fileout)
        os.replace(fileout, file)
    except Exception as err:
        common.fatal_error('Error writing OLS config file: %s' % err)

def ols_config(args):
    logging.debug('OLS config file')
    file = config_filename(False)
    try:
        f = open(file, 'r')
    except Exception as err:
        common.fatal_error('Error opening %s: %s' % (file, err))
    revert = config_filename_revert(False)
    shutil.copy2(file, revert)
    copy_file_details(file, revert)
    # Optimize for the do nothing case
    inCGIRLimit = False
    pastCGIRLimit = 0
    cgroupsAt = 0
    cgroupsLine = ''
    namespaceAt = 0
    namespaceLine = ''
    lineNo = 0
    cgroupsVal = 0
    namespaceVal = 0
    for line in f:
        if pastCGIRLimit == 0:
            if not inCGIRLimit:
                if line.startswith('CGIRLimit'):
                    inCGIRLimit = True
            elif line.find('}') != -1:
                pastCGIRLimit = lineNo
            elif not args.namespace_only and line.startswith('  cgroups'):
                cgroupsLine = line
                cgroupsAt = lineNo
        elif line.startswith('namespace'):
            namespaceLine = line
            namespaceAt = lineNo
            break
        elif line.find('{') != -1:
            break
        lineNo += 1
    f.close()
    if not args.namespace_only and cgroupsLine != '':
        cgroupsVal = re.search(r'[\d]+', cgroupsLine).group()
        logging.debug('OLS cgroups val: %d' % int(cgroupsVal))   
    if namespaceLine != '':
        namespaceVal = re.search(r'[\d]+', namespaceLine).group()
        logging.debug('OLS namespace val: %d' % int(namespaceVal))   
    if args.disable:
        if (args.namespace_only or cgroupsLine == '' or int(cgroupsVal) == 0) and (namespaceLine == '' or int(namespaceVal) == 0):
            logging.debug('OLS config file needs no mod')
            return
    elif (args.namespace_only or (cgroupsLine != '' and int(cgroupsVal) >= args.cgroups)) and namespaceLine != '' and int(namespaceVal) >= args.namespace:
        logging.debug('OLS config file needs no mod')   
        return
    logging.debug('OLS config file needs mod')   
    rewrite_ols_config(args, cgroupsAt, pastCGIRLimit, cgroupsVal, namespaceAt, lineNo, namespaceVal)
    lsws_restart()
        
def ols_validate(args):
    ols_config(args)

def read_oneline_file(file):
    try:
        f = open(file, 'r')
    except Exception as err:
        common.fatal_error('Error opening %s: %s' % (file, err))
    ln = f.readline()
    line = ln.rstrip()
    f.close()
    return line

def read_subtree_control():
    line = read_oneline_file('/sys/fs/cgroup/cgroup.subtree_control')
    pieces = line.split(' ')
    logging.debug('Read line: %s got %s' % (line, ' '.join(pieces)))
    return pieces

def build_delegate(controllers):
    dirname = '/etc/systemd/system/user@.service.d'
    filename = dirname + '/delegate.conf'
    if os.path.exists(filename):
        common.fatal_error("%s already exists - problem in controller management" % filename)
    try:
        os.mkdir(dirname)
    except Exception as err:
        logging.debug("Error creating service directory: %s" % err)
    try:
        w = open(filename, 'w')
    except Exception as err:
        common.fatal_error('Error opening %s: %s' % (filename, err))
    w.write("[Service]\nDelegate=%s\n" % ' '.join(controllers))
    w.close()
    systemctl_daemon_reload()

def subtree_control():
    pieces = read_subtree_control()
    controllers = ['cpu', 'cpuset', 'io', 'memory', 'pids']
    missing = []
    for controller in controllers:
        if not controller in pieces:
            missing.append(controller)
    if len(missing) == 0 or (len(missing) == 1 and missing[0] == 'cpuset'):
        logging.debug('No controller missing')
        return
    logging.debug('Missing the %s controllers' % ' '.join(missing))
    build_delegate(controllers)

def compare_versions(verstr, minver):
    verlist = verstr.split('.')
    minlist = minver.split('.')
    for index, min in enumerate(minlist):
        if index > len(verlist):
            logging.debug('Old version %s < %s (short)' % (verstr, minver))
            return True
        dash_index = verlist[index].find('-')
        if dash_index != -1:
            verlist[index] = verlist[index][0:dash_index]
        if int(min) > int(verlist[index]):
            logging.debug('Old version %s < %s' % (verstr, minver))
            return True
        if int(min) < int(verlist[index]):
            logging.debug('Newer version %s > %s' % (verstr, minver))
            return False
    logging.debug('Equal version %s == %s' % (verstr, minver))
    return False

def get_minver():
    if is_lsws():
        return '6.3'
    return '1.7'
    
def needs_upgrade():
    verstr = read_oneline_file(common.server_root() + '/VERSION')
    return compare_versions(verstr, get_minver())
    
def upgrade():
    autostr = read_oneline_file(common.server_root() + '/autoupdate/release')
    upgrade_pgm = common.server_root() + '/admin/misc/lsup.sh'
    logging.info('Doing LiteSpeed upgrade')
    if not compare_versions(autostr, get_minver()):
        run_shell_program(upgrade_pgm + ' -f')
    else:
        run_shell_program(upgrade_pgm + ' -d -f -v ' + get_minver())
        
def revert_config():
    lsws = is_lsws()
    if not os.path.isfile(config_filename_revert(lsws)):
        common.fatal_error("You do not have a config file to revert")
    logging.info('Doing LiteSpeed config file revert')
    os.replace(config_filename_revert(lsws), config_filename(lsws))
    lsws_restart()

def do_pgm(args):
    logging.debug("Entering setup")
    if args.revert_config:
        revert_config()
        return;
    if not args.no_upgrade:
        if needs_upgrade():
            upgrade()
    if not args.no_config:
        if is_lsws():
            lsws_validate(args)
        else:
            ols_validate(args)
    else:
        logging.debug('no-config specified')
    if not args.disable and not args.no_subtree_control and not args.namespace_only:
        subtree_control()
    else:
        logging.debug('no-subtree_control specified')

def main():
    args = init_pgm()
    return do_pgm(args)
  
if __name__ == "__main__":
    main()
    
