#!/usr/bin/python
# @package      hubzero-mw2-file-service
# @file         maxwell_fs
# @copyright    Copyright (c) 2016-2020 The Regents of the University of California.
# @license      http://opensource.org/licenses/MIT MIT
#
# Based on prior work by Richard L. Kennell and Nicholas Kisseberth
#
# Copyright (c) 2016-2020 The Regents of the University of California.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# HUBzero is a registered trademark of The Regents of the University of California.
#

"""
Maxwell script, to be run on file servers.
This script does not access the SQL database, which simplifies the handling of forks and child
 exits.

Files used by this script:
FILE_CONFIG_FILE : the path to the configuration information.
              Note that fixed paths are set in error.py.


log files for an application:
SERVICE_LOG_PATH/<session number>.err
SERVICE_LOG_PATH/<session number>.out

Configuration information for a particular session and application are stored
inside a user's directory:
resource file = homedir + "/data/sessions/<session number>/resources"
"""

import os
import sys
import pwd
import re
import stat
import subprocess
from hubzero.mw.log import log, setup_log, log_exc, ttyprint, save_out
from hubzero.mw.support import check_rundir
from hubzero.mw.user_account import make_User_account, User_account, User_account_JSON, User_account_anonymous
from hubzero.mw.constants import HOST_K, FILE_CONFIG_FILE, SERVICE_LOG, \
    VERBOSE, USER_REGEXP, SESSNAME_REGEXP, PATH_REGEXP
from hubzero.mw.errors import  MaxwellError, InputError

CONTAINER_CONF = {}
DEBUG = False


#=============================================================================
# Execute a command under numerical uid and gid
#=============================================================================
#=============================================================================
# Check the user's directory
#=============================================================================
def setup_dir(user, session, params=None):
  """
    If necessary, copy the template.environ file in the user's home, using su_uid

    AWS-Docker: there is no local account with those uid and gid!
    Filesystem is either local or NFS exported with no_root_sqash so we can create user homes
  """
  account = User_account_JSON(user, CONTAINER_CONF)
  os.umask(0077) # only user has permissions
  if not os.path.isfile(account.ext_homedir() + "/.environ"):
    if VERBOSE:
      log("copying .environ into home directory at %s" % account.ext_homedir())
    # remove .. from path
    if not os.path.isfile(os.path.dirname(FILE_CONFIG_FILE) + "/template.environ"):
      log("missing template.environ in service directory %s" % os.path.dirname(FILE_CONFIG_FILE))
      return
    # do we need to create the home directory?
    account.create_home()
    cmd = '/bin/cp'
    args = [os.path.dirname(FILE_CONFIG_FILE) + "/template.environ", account.ext_homedir() + "/.environ"]
    retcode = account.su_uid(cmd, args)
    if retcode != 0:
      log("Unable to copy template.environ")
      
  account.relinquish()

  if os.path.isfile(account.session_dir(session)):
    log("session directory already exists (%s, %s)" % (user, session))
    sys.exit(1)
  else:
    os.makedirs(account.session_dir(session))
    session_existed = False

  resources = account.session_dir(session) + "/resources"
  rfile = open(resources, "w")
  rfile.write("sessionid %s\n" % session)
  rfile.write("results_directory %s/data/results/%s\n" % (account.homedir, session))
  rfile.close()
  if VERBOSE:
    log("setup session directory and resources for user '%s' in session %s" % (user, session))

  if params is not None and params != "":
    import urllib2
    params_path = account.params_path(session)
    pfile = open(params_path, "w")
    pfile.write(urllib2.unquote(params).decode("utf8"))

def update_quota(user, block_soft, block_hard):
  """
    Change the quota for a user.
    This happens on a fileserver.
  """
  if block_soft < 0 or block_hard < 0:
    raise InputError("Invalid quotas")
  # don't create home directory because setquota is invoked at the time of user creation
  # don't want empty home directories. There were also LDAP propagation delays
  # account = User_account(user)
  # account.home_quota(CONTAINER_CONF)
  subprocess.check_call(["setquota", '-a', user, "%d" % block_soft, "%d" % block_hard, '0', '0'])

def get_quota(user):
  """
    Get the quotas (soft and hard) for a user account
  """
  process = subprocess.Popen(
    ["quota", "-v", "--no-wrap", user],
    stdin= None,
    stdout= subprocess.PIPE,
    stderr= subprocess.PIPE,
    shell = False
  )
  (stdout, stderr) = process.communicate()
  if process.returncode == 0:
    for line in str(stdout).split("\n"):
      if line[0:20] == "Disk quotas for user":
        continue
      elif line[0:15] == "     Filesystem":
        continue
      else:
        parts = line.split()
        if len(parts) > 2:
          print parts[2]
          print parts[3]
  else:
    raise MaxwellError("Quota for user '%s' error: '%s'" % (user, stderr))

def update_resources(user, session, dispnum):
  """
    Update the resource file for the session.
    Command-level function.  Note that this happens on the fileserver, not the
    execution host.
    dispnum is unused and is there for compatibility with anonymous sessions
    (see maxwell_service)
  """
  os.umask(0077)
  account = make_User_account(user, CONTAINER_CONF)
  resources = account.session_dir(session) + "/resources"
  if DEBUG:
    log("appending to resources file at %s" % resources)

  account.relinquish()

  try:
    rfile = open(resources,"a+")
    # Read data from command line and write to file, until an empty string is found
    while 1:
      line = sys.stdin.readline()
      if line == "":
        break
      rfile.write(line)

    rfile.close()
  except OSError:
    raise MaxwellError("Unable to append to resource file.")

def erase_session_dir(user, session_id):
  """Used for aborting session creation after the session files were setup.

    Command-level function.
  """
  account = make_User_account(user, CONTAINER_CONF)

  account.relinquish()

  args = ['/bin/rm', '-rf', account.session_dir(session_id)]
  subprocess.check_call(args)

def erase_userhome(user):
  account = make_User_account(user, CONTAINER_CONF)
  if str(user).find("zqkg") == 0:
    # OK, this is a test user
    args = ['/bin/rm', '-rf', account.ext_homedir()]
    subprocess.check_call(args)
  else:
    raise MaxwellError("Requested deleting the home directory of a user who isn't a test user")

def move_userhome(userfrom, userto):
  accountfrom = make_User_account(userfrom, CONTAINER_CONF)
  accountto = make_User_account(userto, CONTAINER_CONF)
  if os.path.isdir(accountto.homedir):
    raise MaxwellError("Home directory %s already exists" % accountto.ext_homedir())
  args = ['/bin/mv', accountfrom.ext_homedir(), accountto.ext_homedir()]
  subprocess.check_call(args)

def create_userhome(user):
  account = make_User_account(user, CONTAINER_CONF)
  account.create_home()

def setfacl(facl, toolpath):
  try:
    os.lstat(toolpath)
  except OSError:
    log("tool '%s' doesn't seem to be installed" % toolpath)
    # if tool isn't installed, do nothing
    return
  subprocess.call(['setfacl', "--set", facl, toolpath])

#=============================================================================
# INPUT VALIDATION
#=============================================================================
def validate_user(i):
  """input username validation"""
  m = re.match(USER_REGEXP, sys.argv[i])
  if m is None:
    raise InputError("Bad user ID '%s'" % sys.argv[i])
  return m.group()

def validate_path(i):
  """input path validation"""
  m = re.match(PATH_REGEXP, sys.argv[i])
  if m is None:
    raise InputError("Bad path '%s'" % sys.argv[i])
  return m.group()

def validate_facl(i):
  """input path validation"""
  m = re.match(r'\A[a-zA-Z0-9._\-:,\'\"]+\Z', sys.argv[i])
  if m is None:
    raise InputError("Bad path '%s'" % sys.argv[i])
  return m.group()

def check_nargs(minarg, maxarg=0):
  """Check the number of arguments"""
  if len(sys.argv) < minarg:
    raise InputError("Incomplete command: %s" % " ".join(sys.argv))
  if len(sys.argv) > maxarg and len(sys.argv) > minarg:
    raise InputError("Too many arguments: %s" % " ".join(sys.argv))

def validate_session(i):
  """Validate a session identifier, number + suffix
  suffix: d for development, p for production """
  m = re.match(SESSNAME_REGEXP, sys.argv[i])
  if m is None:
    raise InputError("Bad session ID '%s'" % sys.argv[i])
  return m.group()

#=============================================================================
#=============================================================================
# Main program...
# by session name, we mean an integer followed by a letter
# by sessnum, we mean just the integer.
#
# We recognize these commands on the fileserver:
#  setup_dir <user> <session name>
#  update_resources <user> <session name>
#  erase_sessdir  <user> <session name>
#=============================================================================
#=============================================================================


#=============================================================================
# Configuration and Safety
# This program receives commands from maxwell
# it runs on a different host and so has different rules.
#
# note that hosts have varying capabilities (roles) assigned to them
# if it's a fileserver, it will respond to setup_dir, etc...
#=============================================================================

check_rundir()

# Load the configuration and override the default variables.
# First check that it is safe to do so
try:
  mode = os.lstat(FILE_CONFIG_FILE)[stat.ST_MODE]
  if mode & stat.S_IWOTH:
    print "configuration file is writable by others; exiting.\n"
    sys.exit(1)
  try:
    execfile(FILE_CONFIG_FILE)
  except EnvironmentError:
    print "Unable to read configuration file, exiting."
    print "The configuration file '%s' needs to exist" % FILE_CONFIG_FILE
    sys.exit(1)

except OSError:
  if DEBUG:
    print "no configuration file is present at %s, use defaults" % FILE_CONFIG_FILE
  pass

if DEBUG:
  print "user account class is %s " % CONTAINER_CONF["account_class"]


# check that user is correct
login = pwd.getpwuid(os.geteuid())[0]
if login != HOST_K["SVC_HOST_USER"]:
  print "maxwell: access denied to %s. Must be run as %s (see %s)" \
        % (login, HOST_K["SVC_HOST_USER"], FILE_CONFIG_FILE)
  sys.exit(1)

#=============================================================================
# Input parsing
#=============================================================================

if sys.argv[1] == "check":
  # the only place in this script where we need to print to standard out.
  # However we want to capture stdout and stderr.  If the logging redirection is setup
  # before this point, we can't print unless we call save_out().  Then later startvnc hangs
  # because save_out() prevents the dissociation of processes.
  # One solution is to setup logging after this.  Alternatively, call save_out() and then discard_out().
  
  # Check that acl (setfacl, getfacl) is installed
  args = ['getfacl', '/']
  try:
    p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    p.communicate()
    if p.returncode != 0:
      print("acl does not appear to be installed (getfacl failed)")
      sys.exit(1)
  except OSError:
    print("acl does not appear to be installed (getfacl failed)")
    sys.exit(1)
  print("OK")
  sys.exit(0)

if sys.argv[1] == "get_quota":
  # done before setting up logging because we need to print to stdout
  username = validate_user(2)
  get_quota(username)
  exit(0)

save_out() # this script needs ttyprint functionality
setup_log(SERVICE_LOG, None) # no log id because the log file is unique to this script
if VERBOSE:
  log("received command %s" % " ".join(sys.argv))
try:
  inputs = {}


  if sys.argv[1] == "setup_dir":
    check_nargs(4, 5)
    inputs["user"] = validate_user(2)
    inputs["session"] = validate_session(3)
    if len(sys.argv) > 4:
      inputs["params"] = sys.argv[4]
    else:
      inputs["params"] = None
    setup_dir(**inputs)

  elif sys.argv[1] == "update_resources":
    # dispnum is unused so we don't validate it
    check_nargs(5)
    inputs["user"] = validate_user(2)
    inputs["session"] = validate_session(3)
    inputs["dispnum"] = 0
    update_resources(**inputs)

  elif sys.argv[1] == "erase_sessdir":
    check_nargs(4)
    user_param = validate_user(2)
    sess_param = validate_session(3)
    erase_session_dir(user_param, sess_param)

  elif sys.argv[1] == "erase_userhome":
    check_nargs(3)
    user_param = validate_user(2)
    erase_userhome(user_param)

  elif sys.argv[1] == "create_userhome":
    check_nargs(3)
    user_param = validate_user(2)
    create_userhome(user_param)

  elif sys.argv[1] == "move_userhome":
    check_nargs(4)
    userfrom = validate_user(2)
    userto = validate_user(3)
    move_userhome(userfrom, userto)

  elif sys.argv[1] == "setfacl":
    check_nargs(4)
    facl = sys.argv[2]
    toolpath = validate_path(3)
    setfacl(facl, toolpath)

  elif sys.argv[1] == "update_quota":
    check_nargs(5)
    inputs["block_soft"] = int(sys.argv[3])
    inputs["block_hard"] = int(sys.argv[4])
    users = sys.argv[2].split(",")
    prog = re.compile(USER_REGEXP)
    try:
      validated_users = map(lambda x:prog.match(x).group(0), users)
    except AttributeError:
      raise InputError("Invalid user name passed to update_quota: '%s'" % "' '".join(users))

    for user in validated_users:
      inputs["user"] = user
      update_quota(**inputs)

  else:
    raise InputError("Unknown command: %s" % " ".join(sys.argv))

# attempted conversion of alpha chars to int results in ValueError
except ValueError, e:
  log("Integer input expected for this command: '%s'" % " ".join(sys.argv))
  sys.exit(1)

except InputError, e:
  log("%s" % e)
  if VERBOSE:
    log_exc(e)
  sys.exit(1)

except MaxwellError, e:
  log("%s" % e)
  if VERBOSE:
    log_exc(e)
  sys.exit(2)

except Exception, e:
  log("%s" % e)
  if VERBOSE:
    log_exc(e)
  sys.exit(5)

if VERBOSE:
  log("done")
sys.exit(0)
