# -*- coding: utf-8 -*-
#
#  YaDaemon is a wrapper class with Process::daemon and it uses
#  a typical pid file to protect multiple invocations.
#
#  If you are a root user, you can also change your effective
#  uid and gid.
#  
#  To use this class, simply give your code to the run method
#  with some options. Please refer the Usage section.
#
#=Title
#
#  YaDaemon - a wrapper daemon class using a pid file to protect
#  multiple invocations.
#
#=Scenario of YaDaemon
#
# 1. User creates an instance and call the "run" method.
# 2. Checking already running process.
# 3a. If there is a pid file, checking the pid number written
#    in the pid file. 
#    If there is a running process, then exit.
# 3b. If there is no pid file, touch the pid file.
# 4. Checking the pid file is writable or not.
# 5. If specified, let the process fork by Process::daemon.
# 6. Store the pid number into the defined pid file.
# 7. If you are root user, then call Process::{euid,egid}.
# 8. Yield from the "run" method.
#
#=Usage
#
#  #!/usr/bin/env ruby1.9
#  ## assumption: This script and mydaemon.rb are located in the
#  ## same directory.
#  $:.unshift File::dirname($0)
#  require 'mydaemon'
#  daemon = YaDaemon.new("myappname","myappname.pid","/tmp",
#                        {:daemon=>false,:debug=>true})
#  daemon.run do |pid|   ## pid == /tmp/myappname/myappname.pid
#    ## please write you code like the following;
#    while daemon.running
#      puts Time.now
#      sleep 3
#    end
#  end
#
#==:appname (essential argument)
# * String: a name of this application.
# It must be the unique name and be used for the subdirectory name.
#
#==:pidfile (essential argument)
# * String: a pid file name without path.
#
#==:pidpdir (essential argument)
# * String: a parent pid directory path which will be converted to the 
# absolute path by File::expand_path.
#
# The ":piddir/:appname" directory must be writable by Process::uid.
#
#=Options
#==:debug
#
# * true/false (default: false)
# If it's true, it outputs debug messages into :piddir/:appname/:pidfile.
#
#==:daemon
#
# * true/false (default: false)
#
# If it's true, then the script will be forked and working as 
#  a background process.
#
# If it's false, then working the run method as foreground process.
# If you want to use daemontools distributed by cr.yp.to, this options 
# must be "false."
# 
#==:euid
#
# * Integer (default: Process::euid)
#
# Overwrite Process::euid.
#
#==:egid
#
# * Integer (default: Process::egid)
#
# Overwrite Process::egid.
#
#==:perms
#
# * String or Numeric(Octal)
#
# "0755" or "755" will be converted to a octal variable by the 'oct' method.
# If you use a numeric variable, you must use octal expression, like 0755.
# It means that 755 is wrong expression, but 493 is correct.
#
#=File Permissions
#
# The process pid will be stored into :piddir/:appname/:pidfile, such as
# /tmp/app/app.pid.
# In this case, file permissions of the pidfile and piddir/appname
# directory will be checked.
#
# If you use :debug=true option, the piddir/appname/"debug.log" file 
# will be also created by uid:gid, and then chown-ed by euid:egid.
#
#          |                @pidpath     |             |            |
#          |      @piddir      /         |             |            |
#          | pidpdir / appname / pidfile | "debug.log" | "stop.txt" |
# ---------+---------+---------+---------+-------------+------------+
#  user id |    x    |  @euid= |  <uid>= |   @euid=    |   @euid*   |
# group id |    x    |    -    |    -    |   @egid=    |   @egid=   |
# ---------+---------+---------+---------+-------------+------------+
# (<uid>,<gid> is same as Process::uid, Process::gid)
# ('=' means these ownerships will be overwritten)
# ('*' means it should be writable by that id)
# ('x' means it never be checked and changed)
#
#=Copyright
#
#  Copyright (C) 2010,2011 Yasuhiro ABE <yasu@yasundial.org>
#
#  Licensed under the Apache License, Version 2.0 (the "License");
#  you may not use this file except in compliance with the License.
#  You may obtain a copy of the License at
#  
#       http://www.apache.org/licenses/LICENSE-2.0
#  
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.
#

# It's a utility class to process common tasks.
# These methods should be tested by the unit test script.
#
module YaDaemonUtils

  ## 
  ## Note: 
  ## The following methods having same prefix 'check_' are support methods for the option parser.
  ##
  def check_boolean(opts, label)
    (opts.has_key?(label) and (opts[label].kind_of?(FalseClass) or opts[label].kind_of?(TrueClass))) ? opts[label] : false
  end
  def check_eugid(opts, label)
    label = "uid" if Process::methods.index(label) == nil
    (Process::uid == 0 and opts.has_key?(label) and opts[label].methods.index(:to_i) != nil) ? opts[label].to_i : Process.method(label).call
  end
  def check_default_perms(opts,label)
    perm = 0711
    if opts.has_key?(label)
      if opts[label].kind_of?(String)
        perm = opts[label].oct
      elsif opts[label].kind_of?(Numeric)
        perm = opts[label]
      end
    end
    perm
  end

  ## It's a wrapper method to write something to the given filepath.
  ## args: perms must be fixed num
  ## return: true(if success) or false
  def write_file(filepath, perms, data)
    ret = false
    begin
      open(filepath, File::RDWR|File::CREAT, perms) do |f|
        f.flock(File::LOCK_EX)
        f.write(data.to_s)
        f.flush
        f.truncate(f.pos)
      end
      ret = true
    rescue
      ret = false
    end
    ret    
  end
end

## It is a simple unix-like daemon class.
class YaDaemon

  include YaDaemonUtils

  ## logging message for debug
  def logit(msg)
    return if not @debug
    return if not msg.kind_of?(String)
    puts msg if not @daemon_mode
    open(@debugfile, "a") do |f|
      require 'csv'
      ## buf='';f.write(CSV.generate_row(["pid=#{$$}","date=#{Time.now}","msg=#{msg.to_s}"], 2, buf)) ## ruby 1.8.x
      f.write(["pid=#{$$}","date=#{Time.now}","msg=#{msg.to_s}"].to_csv)
      f.flush
    end
  end
  
  ## raise all exception during initialize 
  def initialize(appname="app", pidfile="app.pid", pidpdir="/tmp",
                 opts = { 
                   :debug=>false,
                   :daemon=>false,
                 })
    ## opts might be null
    opts = {} if not opts.kind_of?(Hash)
    ## check options
    @debug = check_boolean(opts,:debug)
    @daemon_mode = check_boolean(opts,:daemon)
    @euid = check_eugid(opts,:euid)
    @egid = check_eugid(opts,:egid)
    @file_perms = check_default_perms(opts,:perms)

    ## take care the pidpdir which must exists.
    raise "pidpdir, #{pidpdir}, not exist." if not FileTest::exist?(pidpdir)

    ## prepare @piddir and @pidpath
    @piddir = File::join([File::expand_path(pidpdir),appname])
    raise "cannot mkdir(#{piddir})" if not FileTest::exist?(@piddir) and Dir::mkdir(@piddir) != 0
    ## change ownership of @piddir
    stat = File::Stat.new(@piddir)
    if stat.uid != @euid
      if  File::chown(@euid, nil, @piddir) == 1
        logit "initialize: succesfully changed #{@piddir}'s ownership."
      else
        raise "failed to chown #{@piddir}."
      end
    end
    @pidpath = File::join([@piddir, pidfile])
    
    ## prepare the @stopfile
    ## used by run/stop methods and delete if existing.
    @stopfile = File::join([@piddir, "stop.txt"])
    if FileTest.exist?(@stopfile)
      if File::unlink(@stopfile) != 1
        logit "failed to delete the stop file, #{@stopfile}."
        raise "failed to delete the stop file, #{@stopfile}."
      end
    end

    ## prepare the @debugfile
    @debugfile = File::join([@piddir,"debug.log"]) if @debug
    ## change owner,group of @debugfile.
    if @debug and Process::uid == 0
      if not FileTest::exist?(@debugfile) 
        if write_file(@debugfile, @file_perms, "")
          logit "succesfully create the debug.log file, #{@debugfile}."
        else
          logit "failed to touch the debug.log file, #{@debugfile}."
          raise "failed to touch the debug.log file, #{@debugfile}."
        end
      end
      stat = File::Stat.new(@debugfile)
      if stat.uid != @euid or stat.gid != @egid
        if File::chown(@euid, @egid, @debugfile) == 1
          logit "initialize: succesfully changed #{@debugfile}'s ownership."
        else
          logit "failed to chown #{@debugfile}."
          raise "failed to chown #{@debugfile}."
        end
      end
    end
  end

  def get_pid
    logit "get_pid: called"
    pid = 0
    begin
      open(@pidpath).each_line do |l| pid = l.to_i end
    rescue
      logit "get_pid: failed to open the pid file, @pidpath."
    end
    logit "get_pid: return #{pid}"
    pid
  end
  
  ## If a running process was found and it was not the own process, then return true.
  ## There is no running process without myself, then return false.
  def check_proc
    logit "check_proc: called"
    ret = false
    pid = get_pid
    logit "check_proc: pid = #{pid}"

    ## case 0: if pid file is empty then it assumes there is no running process.
    return ret if pid < 3
    ## case 1: check the $$ value at first.
    if pid == $$
      logit("check_proc: the pid file exists, but it has own process number. It's harmless, but should be never called.")
      return ret
    end
    ## case 2a: check /proc/$pid. (available on limited systems only)
    ## skipped.
    ## case 2b: using kill -0 method (widely available on unix-like systems)
    begin
      k = Process::kill(0, pid)
      ret = true if k == 1
    rescue
      logit "check_proc: exception occurred by kill(0, #{pid}): #{$!}"
      ret = false
    end
    logit "check_proc: return #{ret}"
    ret
  end
  
  def create_stop_file
    ret = write_file(@stopfile, @file_perms, "")
    logit "create_stop_file: return #{ret}"
    ret
  end

  ## If there is no running process, then the pid file will be overwritten.
  def overwrite_pid_file
    ret = write_file(@pidpath, @file_perms, Process::pid.to_s)
    logit "overwrite_pid_file: return #{ret}"
    ret
  end
  
  def run
    logit "run: called"
    
    if FileTest::exist?(@pidpath)    
      ## process scenario: A3a
      if check_proc
        logit "run: a process is already running, exiting now."
        return 
      end
    else
      ## process scenario: A2,A3b
      begin
        open(@pidpath,"w") do |f| f.write("") end
      rescue
        logit "run: failed to write the pid file, #{@pidpath}."
        return
      end
    end
    ## comfirm scenario: A4
    if not File::writable?(@pidpath)
      logit "run: pidfile, #{@pidpath}, not writable." 
      return
    end
    
    ## process scenario: A5
    if @daemon_mode
      logit "run: move to daemon mode"
      Process::daemon
    end
    
    ## process scenario: A6
    return if not overwrite_pid_file
    
    ## process scenario: A7
    change_privilege(@egid, :egid)
    change_privilege(@euid, :euid)
    
    ## process scenario: A8
    yield @pidpath
    logit "run: return"
  end
  
  ## safelly 
  def stop
    logit "stop: called"
    
    create_stop_file()
  end
  
  def force_stop
    if not check_proc
      logit "force_stop: this process has already stopped."
      return
    end
    pid = get_pid
    ## case 1
    if pid == $$
      create_stop_file()
      exit(0)
    end
    ## case 2
    if pid > 0
      n = Process::kill(15, pid)
      if n > 0
        logit "force_stop: the terminate signal succesfully sent." 
      else
        logit "force_stop: failed to sent the terminate signal."
      end
      while check_proc 
        logit "stop: waiting terminate process, pid=#{pid}."
        sleep 3
      end
    else
      logit "force_stop: failed to get my pid number" 
    end
    logit "force_stop: return"
  end

  ## change privileges using from run method, but place here for unit test.
  def change_privilege(eugid=0, label=:uid)
    logit "change_privilege: called (eugid=#{eugid}, label=#{label})"
    if Process::uid == 0 and eugid != Process.method(label).call
      ret = Process.method("#{label}=").call(eugid)
      logit "change_privilege: change #{label} to #{eugid}: #{ret}"
    end
    ret
  end

  def running
    not FileTest.exist?(@stopfile)
  end

end
