########################################################################
# Copyright (C) 2010-2018 VMWare, Inc.                                 #
# All Rights Reserved                                                  #
########################################################################

''' This Module provides LockFile class based on fcntl.'''

hasfcntl = True
try:
   import fcntl
except ImportError:
   hasfcntl = False

import logging
import os
import signal
import time

log = logging.getLogger('LockFile')

class LockFileError(Exception):
   '''Unable to open or lock the lock file.'''

class LockFileFormatError(LockFileError):
   '''The change number in the lock file is not of int value.'''

class LockFile(object):
   '''File lock based on fcntl.
      The lock is an exclusive lock. Holder PID is kept in the lock file.
         Attributes:
            * lockfile - The file path of the lock file.
   '''
   def __init__(self, lockfile):
      self._lockfile = lockfile
      self._lockfobj = None

   def __str__(self):
      return '<Lock: %s>' % (self._lockfile)

   @property
   def lockfile(self):
      return self._lockfile

   def Lock(self, blocking=False):
      """Locks the file lock.
         PID is written in the file to indicate lock holder.
         On error, the method will free the file object, otherwise
         it will be kept until the time of unlock.
            Parameters:
               * blocking - if True, the call will block until the lock is
                            freed up.
                            If False, and the lock cannot be obtained right
                            away, a LockFileError exception will be raised.
            Raises:
               * LockFileError       - If the lock file cannot be opened or
                                       locked.
               * LockFileFormatError - If the content of lock file is
                                       unexpected.
      """
      if not hasfcntl:
         msg = 'Python module "fcntl" is not available.'
         raise LockFileError(msg)

      try:
         # If we already have an open file descriptor for the lock file,
         # do not open another one.
         if self._lockfobj:
            fd = self._lockfobj.fileno()
         else:
            fd = os.open(self._lockfile, os.O_RDWR | os.O_CREAT)
            self._lockfobj = os.fdopen(fd, 'r+')
      except Exception as e:
         if self._lockfobj:
            self._lockfobj.close()
            self._lockfobj = None
         msg = 'Error opening lock file %s: %s' % (self._lockfile, str(e))
         log.error(msg)
         raise LockFileError(msg)

      if blocking:
         flag = 0
      else:
         flag = fcntl.LOCK_NB

      try:
         fcntl.lockf(fd, fcntl.LOCK_EX | flag)
      except Exception as e:
         pid = self._readPID()
         msg = 'Error locking file %s: %s, the file is currently locked ' \
               'by process with PID %s' % (self._lockfile, str(e), str(pid))
         log.error(msg)
         self._lockfobj.close()
         self._lockfobj = None
         raise LockFileError(self._lockfile, msg)

      # Write our PID
      self._writePID()

   def Unlock(self):
      """Unlocks the file lock.
            Raises:
               * LockFileError - If the file cannot be unlocked.
      """
      if not hasfcntl:
         msg = 'Python module "fcntl" is not available.'
         raise LockFileError(msg)

      if self._lockfobj is None:
         return

      try:
         fcntl.flock(self._lockfobj, fcntl.LOCK_UN)
         self._lockfobj.close()
         self._lockfobj = None
         # Remove the file so no one will assume the file is still locked
         # and read the PID for owner.
         try:
            os.remove(self._lockfile)
         except FileNotFoundError:
            log.warn('Error removing lock file %s: file does not exist'
                     % self._lockfile)
      except Exception as e:
         msg = "Error unlocking file %s: %s" % (self._lockfile, str(e))
         log.error(msg)
         raise LockFileError(msg)

   def _writePID(self):
      """Write PID in the lock file.
         The write is explicitly flushed for read by another process.
         This method is called after lock succeeded, any exception will be
         logged and ignored, lock will still be held.
      """
      try:
         if self._lockfobj:
            self._lockfobj.truncate(0)
            self._lockfobj.seek(0, 0)
            self._lockfobj.write(str(os.getpid()))
            self._lockfobj.flush()
            os.fsync(self._lockfobj.fileno())
      except Exception as e:
         msg = 'Error writing PID in lock file %s: %s' \
               % (self._lockfile, str(e))
         log.error(msg)

   def _readPID(self):
      """Read PID from the lock file.
         This method is called when an attempt to lock fails, any exception
         will be logged and ignored.
      """
      try:
         if self._lockfobj:
            self._lockfobj.seek(0, 0)
            # 50 digit limit is just there to keep us from reading a whole bunch
            # in case that this file contains something we don't expect.
            data = self._lockfobj.read(50)
            if not data:
               log.error('Cannot read holder PID of lock file %s: the file is '
                         'empty' % self._lockfile)
               return None
            return int(data)
         else:
            log.error('Cannot read holder PID: lock object is not initialized')
            return None
      except ValueError as e:
         msg = 'Cannot read holder PID of lock file %s, invalid content: %s' \
               % (self._lockfile, str(e))
         log.error(msg)
         return None
      except Exception as e:
         msg = 'Error reading lock file %s: %s' % (self._lockfile, str(e))
         log.error(msg)
         return None

def _acquireLockPolling(flock, timeout):
   """Acquire an exclusive file lock using a polling-based approach.
   This is a fallback in case SIGALRM is not available.
   """
   blocking = (timeout == -1)
   startTime = time.monotonic()
   while True:
      try:
         flock.Lock(blocking=blocking)
         break
      except LockFileError as e:
         if (time.monotonic() - startTime) >= timeout:
            log.error('LockFile %s timed out after %s seconds'
                      % (flock.lockfile, str(timeout)))
            raise
         # Check again in 1 second
         time.sleep(1)
         log.error('Failed to lock LockFile %s: %s, retrying'
                   % (flock.lockfile, str(e)))
   return flock

def acquireLock(filePath, timeout=10):
   """Acquire an exclusive file lock with a timeout.
      -1 = block-forever; 0 = non-blocking.
      Raises LockFileError if lock not available.
      Returns the LockFile object.
   """
   flock = LockFile(filePath)

   try:
      prevHandler = None
      prevAlarm = None
      if timeout > 0:
         existingAlarm = signal.alarm(0)
         if existingAlarm:
            # Alarm currently exists. Restore it and fall back.
            signal.alarm(existingAlarm)
            log.debug('Existing alarm (%d s); falling back to polling',
                      existingAlarm)
            return _acquireLockPolling(flock, timeout)

         def handler(signum, frame):
            """Signal handler for this lockfile timeout.
            """
            raise LockFileError(
               'LockFile {} timed out after {} seconds'
               .format(filePath, timeout)
            )
         prevHandler = signal.signal(signal.SIGALRM, handler)
         prevAlarm = signal.alarm(timeout)

      # We need "blocking" behavior even with a positive timeout.
      # With a timeout, the alarm we just set will interrupt the wait.
      flock.Lock(blocking=(timeout != 0))
   finally:
      # Restore previous alarm and handler, if any.
      if prevHandler is not None:
         signal.signal(signal.SIGALRM, prevHandler)
      if prevAlarm is not None:
         signal.alarm(prevAlarm)

   return flock

