#
# Whimsy pubsub support: watches for updates to the whimsy repository,
# fetches the changes and deploys them.
#
# For usage instructions, try
#
#   ruby pubsub.rb --help
#

require 'optparse'
require 'ostruct'
require 'etc'
require 'net/http'
require 'json'
require 'fileutils'

$stdout.sync = true # unbuffered output

def stamp(*s)
  line = caller[0].split(':')[1]
  '%s: @%s %s' % [Time.now.gmtime.to_s, line, s.join(' ')]
end

# extract script name
script = File.basename(__FILE__, '.rb')

#
### option parsing
#

ARGV_COPY = ARGV.dup # the parser removes options from ARGV
puts stamp "Initial args: #{ARGV}"

options = OpenStruct.new
options.remote = 'https://gitbox.apache.org/repos/asf/whimsy.git'
options.local = '/srv/whimsy'
options.pidfile = "/var/run/#{script}.pid"
options.streamURL = 'http://pubsub.apache.org:2069/git/'
options.puppet = false

optionparser = OptionParser.new do |opts|
  opts.on '-u', '--user id', "Optional user to run #{script} as" do |user|
    options.user = user
  end

  opts.on '-g', '--group id', "Optional group to run #{script} as" do |group|
    options.group = group
  end

  opts.on '-p', '--pidfile path', 'Optional pid file location' do |path|
    options.pidfile = path
  end

  opts.on '-d', '--daemonize', 'Run as daemon' do
    options.daemonize = true
  end

  opts.on '--puppet', 'Use puppet agent to update' do
    options.puppet = true
  end

  opts.on '-s', '--stream', 'StreamURL' do |url|
    options.streamURL = url
  end

  opts.on '-r', '--remote', 'Git Clone URL' do |url|
    options.streamURL = url
  end

  opts.on '-c', '--clone', 'Git Clone Directory' do |path|
    options.local = path
  end

  opts.on '--stop', "Kill the currently running #{script} process" do
    options.kill = true
  end
end

optionparser.parse!

# Check for required tools

if options.puppet and `which puppet 2>/dev/null`.empty?
  STDERR.puts 'puppet not found in path; exiting'
  exit 1
end

%w(git rake).each do |tool|
  if `which #{tool} 2>/dev/null`.empty?
    STDERR.puts "#{tool} not found in path; exiting"
    exit 1
  end
end

#
### process management
#

# Either kill old process, or start a new one
if options.kill
  if File.exist? options.pidfile
    Process.kill 'TERM', File.read(options.pidfile).to_i
    File.delete options.pidfile if File.exist? options.pidfile
    exit 0
  end
else
  # optionally daemonize
  Process.daemon if options.daemonize

  # Determine if pidfile is writable
  if File.exist? options.pidfile
    writable = File.writable? options.pidfile
  else
    writable = File.writable? File.dirname(options.pidfile)
  end

  # PID file management
  if writable
    File.write options.pidfile, Process.pid.to_s
    at_exit { File.delete options.pidfile if File.exist? options.pidfile }
  else
    STDERR.puts stamp "EACCES: Skipping creation of pidfile #{options.pidfile}"
  end
end

# Optionally change user/group
if Process.uid == 0
  Process::Sys.setgid Etc.getgrnam(options.group).gid if options.group
  Process::Sys.setuid Etc.getpwnam(options.user).uid if options.user
end

# Perform initial clone
if not Dir.exist? options.local
  FileUtils.mkdir_p File.basename(options.local)
  system('git', 'clone', options.remote, options.local)
end

#
# Monitor PubSub endpoint (see https://infra.apache.org/pypubsub.html)
#

PROJECT = File.basename(options.remote, '.git')

ALIVE = File.join('/tmp', "#{File.basename(__FILE__)}.alive") # Help detect failures

# prime the pump
restartable = false
notification_queue = Queue.new
notification_queue.push 'project' => PROJECT

ps_thread = Thread.new do
  begin
    uri = URI.parse(options.streamURL)

    Net::HTTP.start(uri.host, uri.port, :open_timeout => 30) do |http|
      request = Net::HTTP::Get.new uri.request_uri

      http.request request do |response|
        body = ''
        response.read_body do |chunk|
          # Looks like the service only sends \n terminators now
          if chunk =~ /\r?\n$|\0$/
            notification = JSON.parse(body + chunk.chomp("\0"))
            body = ''

            FileUtils.touch(ALIVE)
            if notification['stillalive']
              restartable = true
            elsif notification['push']
              notification_queue << notification['push']
            elsif notification['commit']
              notification_queue << notification['commit']
            elsif notification['svnpubsub']
              next
            else
              STDERR.puts stamp '*** unexpected notification ***'
              STDERR.puts stamp notification.inspect
            end
          else
            body += chunk
          end
        end
      end
    end
  rescue Errno::ECONNREFUSED => e
    restartable = true
    STDERR.puts stamp e
    sleep 3
  rescue Exception => e
    STDERR.puts stamp e
    STDERR.puts stamp e.backtrace
  end
  puts stamp 'Thread ended'
  notification_queue << {} # wake up waiting process
end

#
# Process queued requests
#

begin
  mtime = File.mtime(__FILE__)
  while ps_thread.alive?
    notification = notification_queue.pop
    next unless notification['project'] == PROJECT
    puts stamp 'Detected notification for our project'
    notification_queue.clear

    if options.puppet
      # puppet agent -t has the following exit codes:
      # 0: The run succeeded with no changes or failures; the system was already in the desired state.
      # 1: The run failed, or wasn´t attempted due to another run already in progress.
      # 2: The run succeeded, and some resources were changed.
      # 4: The run succeeded, and some resources failed.
      # 6: The run succeeded, and included both changes and failures.
      # Only attempt a restart if the run failed entirely
      #
      # Update using puppet.  If puppet fails, it may be due to puppet already
      # running; in which case it may not have picked up this update.  So try
      # again in 30, 60, 90, and 120 seconds, for a total of five minutes.
      4.times do |i|
        puts stamp "Starting Puppet"
        system('puppet', 'agent', '-t')
        status = $?.exitstatus
        puts stamp "Puppet completed with status #{status}"
        break unless status == 1
        puts stamp "Failed to run Puppet, will try again shortly"
        sleep 30 * (i+1)
      end
    else
      puts stamp 'Update git in foreground'
      # update git directories in the foreground
      Dir.chdir(options.local) do
        before = `git log --oneline -1`
        system('git', 'fetch', 'origin')
        system('git', 'clean', '-df')
        system('git', 'reset', '--hard', 'origin/master')
        if File.exist? 'Rakefile' and `git log --oneline -1` != before
          system('rake', 'update')
        end
      end
    end
    if mtime != File.mtime(__FILE__) # we have been updated
      puts stamp 'Detected self update'
      break
    end
  end
  puts stamp 'Queue processing ended'
rescue SignalException => e
  STDERR.puts stamp e.inspect
  restartable = false
rescue Exception => e
  STDERR.puts stamp e
  STDERR.puts stamp e.backtrace
  if ps_thread.alive?
    restartable = false # why?
  end
end
puts stamp 'Preparing to exit'

#
# restart
#

if restartable
  STDERR.puts stamp 'restarting'

  # relaunch script after a one second delay
  sleep 1
  puts stamp "Relaunching with args: #{ARGV_COPY}"
  exec RbConfig.ruby, __FILE__, *ARGV_COPY
else
  STDERR.puts stamp 'not restartable'
end
