#!/usr/bin/ruby
#
# NAME
#
#   sympl-web-rotate-logs - Rotate and prune Apache logs.
#
# SYNOPSIS
#
#  sympl-web-rotate-logs [ -n | --max-rotations <n> ] [ -c | --compress-at <n> ] 
#                        [ --prefix | -p <directory> ] [ -h | --help ]
#                        [-m | --manual] [ -v | --verbose ]
#
# OPTIONS
#
#  -n, --max-rotations <n>   Maximum number of rotations to keep, defaults to 30.
#
#  -c, --compress-at <n>     Rotation at which the log file should be
#                            compressed, defaults to 2.
#
#  -p, --prefix <directory>  Prefix directory, defaults to /srv.
#
#  -h, --help                Show a help message, and exit.
#
#  -m, --manual              Show this manual, and exit.
#
#  -v, --verbose             Show verbose errors
#
# USAGE
#
# This script is designed to be invoked once per day and rotate the current
# apache access and error logfiles beneath each domains public directory.
#
# AUTHOR
#
#   Steve Kemp <steve@bytemark.co.uk>
#


#
# Standard ruby
#
require 'getoptlong'
require 'fileutils'


#
#  The options set by the command line.
#
help          = false
manual        = false
$VERBOSELOCAL      = false
max_rotations = 30
prefix        = '/srv'
compress_at   = 2

opts = GetoptLong.new(
                      [ '--max-rotations', '-n', GetoptLong::REQUIRED_ARGUMENT],
                      [ '--compress-at', '-c', GetoptLong::REQUIRED_ARGUMENT],
                      [ '--prefix',     '-p', GetoptLong::REQUIRED_ARGUMENT],
                      [ '--help',       '-h', GetoptLong::NO_ARGUMENT ],
                      [ '--manual',     '-m', GetoptLong::NO_ARGUMENT ],
                      [ '--verbose',    '-v', GetoptLong::NO_ARGUMENT ]
                      )


begin
  opts.each do |opt,arg|
    case opt
    when '--max-rotations'
      max_rotations = arg.to_i
    when '--compress-at'
      compress_at = arg.to_i
    when '--prefix'
      prefix = arg
    when '--help'
      help = true
    when '--manual'
      manual = true
    when '--verbose'
      $VERBOSELOCAL = true
    end
  end
rescue => err
  # any errors, show the help
  warn err.to_s
  help = true
end


#
# Show the manual, or the help
#
#
# CAUTION! Here be quality kode.
#
if manual or help
  # Open the file, stripping the shebang line
  lines = File.open(__FILE__){|fh| fh.readlines}[1..-1]

  found_synopsis = false

  lines.each do |line|

    line.chomp!
    break if line.empty?

    if help and !found_synopsis
      found_synopsis = (line =~ /^#\s+SYNOPSIS\s*$/)
      next
    end

    puts line[2..-1].to_s

    break if help and found_synopsis and line =~ /^#\s*$/

  end

  exit 0
end

# Moved until after help/manual to avoid build dependency.
require 'symbiosis/utils'

#
# Symbiosis libraries -- required here so they're not needed during the build
# process for manpage generation.
#
require 'symbiosis/domains'
require 'symbiosis/domain/http'

def verbose(s) ; puts s if $VERBOSELOCAL ; end

#
# This flag determines if we need to rotate
#
rotated = false

#
# This matches our filenames such that
#
# $1 = ssl_access.log, access.log, ssl_error.log or error.log
# $2 = a number
# $3 = .gz, if the file is compressed.
#
FILENAME_REGEXP = /\b((?:ssl_)?(?:access|error)\.log)(?:\.(\d+))?(\.gz)?$/

#
#  Potentially we process each domain.
#
Symbiosis::Domains.each(prefix) do |domain|
  verbose "Considering domain: #{domain}"

  #
  # Skip symlinks
  #
  if ( domain.is_alias? )
    verbose "\tSkipping as it is an symlink to #{domain.directory}."
    next
  end

  #
  # Check the log directory exists.
  #
  unless ( File.exist?(domain.log_dir) )
    verbose "\tSkipping as #{domain.log_dir} doesn't exist."
    next
  end

  #
  # OK now we need to look for a logfile, or two.
  #
  # We will find files of the form:
  #
  #  access.log
  #  access.log.1
  #  access.log.2.gz
  #
  # We want to increment the version of each file.  We do that by
  # sorting asci-betically
  #
  results = Array.new()

  Dir.foreach( domain.log_dir ) do |entry|
    # skip dotfiles
    next if entry == '.' or entry == '..'

    # skip files that don't match our expected pattern.
    next unless ( entry =~ FILENAME_REGEXP ) 

    # save the file
    results.push( entry )
  end

  #
  # If we didn't find any logfiles we're done.
  #
  if ( results.empty? )
    verbose "\tSkipping this domain, no suitable logfiles found"
    next
  end

  #
  #  OK sort the array by the trailng digit to give us:
  #
  #  access.log
  #  ssl_access.log
  #  access.log.1
  #  ssl_access.log.1
  #  access.log.2.gz
  #  ssl_access.log.2.gz
  #  ...
  #  access.log.10.gz
  #  access.log.11.gz
  #
  # It doesn't matter that the filenames are interspersed, since we create the
  # destination name from the filename itself, not the next one in the array.
  #
  a = results.sort_by do |x|
    #
    # This returns the numeric extension, or zero if none exists.
    #
    x.split(".")[2].to_i
  end

  #
  # If we've got this far, rotation has happened.
  #
  rotated = true

  #
  # Drop privileges
  #
  if 0 == Process.uid
    Process::Sys.setegid(domain.gid)
    Process::Sys.seteuid(domain.uid)
  end

  #
  #  Process these files backward so we don't over-write anything.
  #
  a.reverse.each do |file|
    file = File.join( domain.log_dir, file )
    num = 0
    #
    # This next bit relies on the fact that nil.to_i returns zero.
    #
    dest = file.sub(FILENAME_REGEXP){|s| num = $2.to_i+1 ;  "#{$1}.#{num}#{$3.to_s}" }

    #
    # Don't move if the destination would exist
    #
    next if num > max_rotations

    #
    # Move the file by reading the old one, and writing to the new one. We
    # always want to overwrite what is in the way.
    #
    verbose("\tMoving #{file} -> #{dest}")
    begin
      FileUtils.mv(file, dest, :force => true)
      #
      # Compress the file, if needed.
      #
      if compress_at == num
       system("gzip -9 #{dest}") or warn "Failed to compress #{dest}" 
      end
    rescue Errno::EPERM => err
      warn "** Failed to rotate #{file} -- #{err.to_s}"
      #
      # Don't rotate any further.
      #
      break
    end
  end
  
  #
  # Restore back to root.
  #
  if 0 == Process.uid
    Process::Sys.seteuid(0)
    Process::Sys.setegid(0)
  end
end

#
# If we did anything then we need to reload
#
if ( rotated )
  #
  # TODO:  Really we want to send HUP to those processes which have the
  # logfiles we've rotated open, rather than blindly stabbing things.
  #
  verbose( "Since we rotated we now restart the httpd logger")
  Kernel.system( "killall -HUP sympl-web-logger" )
  
  #
  # Add similar arguments on to the generate-stats command as wot we received.
  #
  stats_args  = " --prefix #{prefix}"
  stats_args += " --verbose" if $VERBOSELOCAL
  verbose( "Since we rotated we now generate statistics" )
  Kernel.system( "/usr/sbin/sympl-web-generate-stats #{stats_args}" ) 
end


