#!/usr/bin/python

# (c) 2012, Michael DeHaan <michael.dehaan@gmail.com>
#
# This file is part of Ansible
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.

try:
    import json
except ImportError:
    import simplejson as json
import os
import sys
import shlex
import subprocess
import shutil
import stat
import grp
import pwd
try:
    import selinux
    HAVE_SELINUX=True
except ImportError:
    HAVE_SELINUX=False

def debug(msg):
    # ansible ignores stderr, so it's safe to use for debug
    # print >>sys.stderr, msg
    pass

def exit_json(rc=0, **kwargs):
    if 'path' in kwargs:
        debug("adding path info")
        add_path_info(kwargs)
    print json.dumps(kwargs)
    sys.exit(rc)

def fail_json(**kwargs):
    kwargs['failed'] = True
    exit_json(rc=1, **kwargs)

def add_path_info(kwargs):
    path = kwargs['path']
    if os.path.exists(path):
        (user, group) = user_and_group(path)
        kwargs['user']  = user
        kwargs['group'] = group
        st = os.stat(path)
        kwargs['mode']  = oct(stat.S_IMODE(st[stat.ST_MODE]))
        # secontext not yet supported
        if os.path.islink(path):
            kwargs['state'] = 'link'
        elif os.path.isfile(path):
            kwargs['state'] = 'file'
        else:
            kwargs['state'] = 'directory'
        if HAVE_SELINUX and selinux_enabled():
            kwargs['secontext'] = ':'.join(selinux_context(path))
    else:
        kwargs['state'] = 'absent'
    return kwargs 

# Detect whether using selinux that is MLS-aware.
# While this means you can set the level/range with
# selinux.lsetfilecon(), it may or may not mean that you
# will get the selevel as part of the context returned
# by selinux.lgetfilecon().
def selinux_mls_enabled():
    if not HAVE_SELINUX:
        return False
    if selinux.is_selinux_mls_enabled() == 1:
        debug('selinux mls is enabled')
        return True
    else:
        debug('selinux mls is disabled')
        return False

def selinux_enabled():
    if not HAVE_SELINUX:
        return False
    if selinux.is_selinux_enabled() == 1:
        debug('selinux is enabled')
        return True
    else:
        debug('selinux is disabled')
        return False

# Determine whether we need a placeholder for selevel/mls
def selinux_initial_context():
    context = [None, None, None]
    if selinux_mls_enabled():
        context.append(None)
    return context

# If selinux fails to find a default, return an array of None
def selinux_default_context(path, mode=0):
    context = selinux_initial_context()
    if not HAVE_SELINUX or not selinux_enabled():
        return context
    try:
        ret = selinux.matchpathcon(path, mode)
    except OSError:
        debug("no default context available")
        return context
    if ret[0] == -1:
        debug("no default context available")
        return context
    context = ret[1].split(':')
    debug("got default secontext=%s" % ret[1])
    return context

def selinux_context(path):
    context = selinux_initial_context()
    if not HAVE_SELINUX or not selinux_enabled():
        return context
    try:
        ret = selinux.lgetfilecon(path)
    except:
        fail_json(path=path, msg='failed to retrieve selinux context')
    if ret[0] == -1:
        return context
    context = ret[1].split(':')
    debug("got current secontext=%s" % ret[1])
    return context

# ===========================================

argfile = sys.argv[1]
args    = open(argfile, 'r').read()
items   = shlex.split(args)

if not len(items):
    fail_json(msg='the module requires arguments -a')
    sys.exit(1)

params = {}
for x in items:
    (k, v) = x.split("=")
    params[k] = v

state     = params.get('state','file')
path      = params.get('path', params.get('dest', params.get('name', None)))
if path:
   path = os.path.expanduser(path)
src       = params.get('src', None)
if src:
   src = os.path.expanduser(src)
dest      = params.get('dest', None)
mode      = params.get('mode', None)
owner     = params.get('owner', None)
group     = params.get('group', None)

# presently unused, we always use -R (FIXME?)
recurse   = params.get('recurse', 'false')

# selinux related options
seuser    = params.get('seuser', None)
serole    = params.get('serole', None)
setype    = params.get('setype', None)
selevel   = params.get('serange', 's0')
context   = params.get('context', None)
secontext = [seuser, serole, setype]
if selinux_mls_enabled():
    secontext.append(selevel)

if context is not None:
    if context != 'default':
        fail_json(msg='invalid context: %s' % context)
    if seuser is not None or serole is not None or setype is not None:
        fail_json(msg='cannot define context=default and seuser, serole or setype')
    secontext = selinux_default_context(path)

if state not in [ 'file', 'directory', 'link', 'absent']:
    fail_json(msg='invalid state: %s' % state)

if state == 'link' and (src is None or dest is None):
    fail_json(msg='src and dest are required for "link" state')
elif path is None:
    fail_json(msg='path is required')

changed = False

# ===========================================
# support functions

def md5sum(filename):
    return os.popen("/usr/bin/md5sum %s" % f).read()

def user_and_group(filename):
    st = os.stat(filename)
    uid = st.st_uid
    gid = st.st_gid
    user = pwd.getpwuid(uid)[0]
    group = grp.getgrgid(gid)[0]
    debug("got user=%s and group=%s" % (user, group))
    return (user, group)

def set_context_if_different(path, context, changed):
    if not HAVE_SELINUX or not selinux_enabled():
        return changed
    cur_context = selinux_context(path)
    new_context = list(cur_context)
    debug("current secontext is %s" % ':'.join(cur_context))
    # Iterate over the current context instead of the
    # argument context, which may have selevel.
    for i in range(len(cur_context)):
        if context[i] is not None and context[i] != cur_context[i]:
            new_context[i] = context[i]
    debug("new secontext is %s" % ':'.join(new_context))
    if cur_context != new_context:
        try:
            rc = selinux.lsetfilecon(path, ':'.join(new_context))
        except OSError:
            fail_json(path=path, msg='invalid selinux context')
        if rc != 0:
            fail_json(path=path, msg='set selinux context failed')
        changed = True
    return changed
    
def set_owner_if_different(path, owner, changed):
   if owner is None:
       debug('not tweaking owner')
       return changed
   user, group = user_and_group(path)
   if owner != user:
       debug('setting owner')
       rc = os.system("/bin/chown -R %s %s" % (owner, path))
       if rc != 0:
           fail_json(path=path, msg='chown failed')
       return True

   return changed
    
def set_group_if_different(path, group, changed):
   if group is None:
       debug('not tweaking group')
       return changed
   old_user, old_group = user_and_group(path)
   if old_group != group:
       debug('setting group')
       rc = os.system("/bin/chgrp -R %s %s" % (group, path))
       if rc != 0:
           fail_json(path=path, msg='chgrp failed')
       return True
   return changed

def set_mode_if_different(path, mode, changed):
   if mode is None:
       debug('not tweaking mode')
       return changed
   try:
       # FIXME: support English modes
       mode = int(mode, 8)
   except Exception, e:
       fail_json(path=path, msg='mode needs to be something octalish', details=str(e))  
 
   st = os.stat(path)
   prev_mode = stat.S_IMODE(st[stat.ST_MODE])

   if prev_mode != mode:
       # FIXME: comparison against string above will cause this to be executed
       # every time
       try:
           debug('setting mode')
           os.chmod(path, mode)
       except Exception, e:
           fail_json(path=path, msg='chmod failed', details=str(e))

       st = os.stat(path)
       new_mode = stat.S_IMODE(st[stat.ST_MODE])

       if new_mode != prev_mode:
           return True
   return changed


def rmtree_error(func, path, exc_info):
   fail_json(path=path, msg='failed to remove directory')

# ===========================================
# go...

prev_state = 'absent'
if os.path.exists(path):
    if os.path.islink(path):
        prev_state = 'link'
    elif os.path.isfile(path):
        prev_state = 'file'
    else:
        prev_state = 'directory'

if prev_state != 'absent' and state == 'absent':
    debug('requesting absent')
    try:
        if prev_state == 'directory':
            if os.path.islink(path):
                os.unlink(path)
            else:
                shutil.rmtree(path, ignore_errors=False, onerror=rmtree_error)
        else:
            os.unlink(path)
    except Exception, e:
        fail_json(path=path, msg=str(e))
    exit_json(path=path, changed=True)
    sys.exit(0)

if prev_state != 'absent' and prev_state != state:
    fail_json(path=path, msg='refusing to convert between %s and %s' % (prev_state, state))

if prev_state == 'absent' and state == 'absent':
    exit_json(path=path, changed=False)

if state == 'file':

    debug('requesting file')
    if prev_state == 'absent':
        fail_json(path=path, msg='file does not exist, use copy or template module to create')

    # set modes owners and context as needed
    changed = set_context_if_different(path, secontext, changed)
    changed = set_owner_if_different(path, owner, changed)
    changed = set_group_if_different(path, group, changed)
    changed = set_mode_if_different(path, mode, changed)

    exit_json(path=path, changed=changed)

elif state == 'directory':

    debug('requesting directory')
    if prev_state == 'absent':
        os.makedirs(path)
        changed = True
 
    # set modes owners and context as needed
    changed = set_context_if_different(path, secontext, changed)
    changed = set_owner_if_different(path, owner, changed)
    changed = set_group_if_different(path, group, changed)
    changed = set_mode_if_different(path, mode, changed)

    exit_json(path=path, changed=changed)

elif state == 'link':
    
    if os.path.isabs(src):
        abs_src = src
    else:
        abs_src = os.path.join(os.path.dirname(dest), src)
    if not os.path.exists(abs_src):
        fail_json(dest=dest, src=src, msg='src file does not exist')
   
    if prev_state == 'absent':
        os.symlink(src, dest)
        changed = True
    elif prev_state == 'link':
        old_src = os.readlink(dest)
        if not os.path.isabs(old_src):
            old_src = os.path.join(os.path.dirname(dest), old_src)
        if old_src != src:
            os.unlink(dest)
            os.symlink(src, dest)
    else:
        fail_json(dest=dest, src=src, msg='unexpected position reached')

    # set modes owners and context as needed
    changed = set_context_if_different(dest, secontext, changed)
    changed = set_owner_if_different(dest, owner, changed)
    changed = set_group_if_different(dest, group, changed)
    changed = set_mode_if_different(dest, mode, changed)

    exit_json(dest=dest, src=src, changed=changed)


fail_json(path=path, msg='unexpected position reached')
sys.exit(0)