#!/usr/bin/ruby -w
##############################################################################
# This is a node tagging script for etch which gets tags from nVentory
# http://sourceforge.net/apps/trac/etch/wiki/ControlledDeployment
##############################################################################

require 'optparse'
require 'nventory'

#
# Parse the command line options
#

@debug = false

opts = OptionParser.new
opts.banner = "Usage: #{File.basename($0)} [--debug] <hostname>"
opts.on('--debug', "Print messages about how the client's tag is determined") do |opt|
  @debug = opt
end

opts.on_tail("-h", "--help", "Show this message") do
  puts opts
  exit
end

name = nil
leftovers = opts.parse(ARGV)
if leftovers.size == 1
  name = leftovers.first
else
  puts opts
  exit
end

#
# This script normally runs only on the etch servers and thus doesn't need to
# worry about timezone differences under standard operation.  But for
# development and troubleshooting users might run it elsewhere.  In which case
# it needs to execute with the same timezone setting as the etch servers.
#

ENV['TZ'] = 'UTC'

#
# Load the tag state data
#
# This allows users to mark tags as good or bad.  Here's a scenario to
# explain why you want to be able to mark tags as bad.  Imagine you check
# in a bad change at 0800.  Around 1000 you notice that your dev and qa
# environments are broken and commit a fix.  That fix ends up in the 1100
# tag.  However, staging and production are still going to get the 0800,
# 0900 and 1000 tags before they get to your 1100 tag with the fix.  You
# need a way to tell the system to skip over those bad tags.  If you mark
# 0800, 0900 and 1000 as bad then dev and qa will revert to 0700 (the last
# non-bad tag), and staging and production will hold at 0700.  Then the
# 1100 tag will work its way through the environments as usual.  Disaster
# averted.
#
# Marking tags as good doesn't currently do anything, but could be used to
# implement a human review or change management process where only known-good
# tags are allowed to propagate to production.
#
# The rollback behavior doesn't work well when the bad change is a new
# file and the last not-bad tag doesn't contain any configuration for
# that file so the bad configuration remains in place until the fixed
# configuration catches up, which could be 5 hours.  In those cases you
# can mark the bad tags with 'badfwd' if you want to have clients roll
# forward right away to the next not-bad tag so they pick up the fixed
# configuration right away.
#
@tagstate = {}
valid_states = ['good', 'bad', 'badfwd']
tagstatefile = File.join(File.dirname(__FILE__), 'tagstate')
if File.exist?(tagstatefile)
  IO.foreach(tagstatefile) do |line|
    next if line =~ /^\s*$/  # Skip blank lines
    next if line =~ /^\s*#/  # Skip comments
    tag, state = line.split
    if valid_states.include?(state)
      @tagstate[tag] = state
    else
      warn "Ignoring state #{state} for tag #{tag}, it's not one of the valid states: #{valid_states.join(',')}"
    end
  end
end

# Shared with the repo_update script
def convert_tagtime_to_unixtime(tagdate, tagtime)
  year, month, day = tagdate.unpack('A4A2A2')
  hour, minute = tagtime.unpack('A2A2')
  unixtime = Time.local(year, month, day, hour, minute, 0, 0)
  unixtime
end

def tagforhours(hours)
    Time.at(Time.now - hours * 60 * 60).strftime('etchautotag-%Y%m%d-%H00')
end

def findautotag(hoursago, needgoodtag=false)
  tag = nil

  # Check the state for the 'hoursago' tag.  If it is 'badfwd' then look
  # forward for the next not-bad or good tag (depending on
  # 'needgoodtag').  Otherwise check from that point backwards.
  hoursagotag = tagforhours(hoursago)
  hours_to_check = []
  if @tagstate[hoursagotag] == 'badfwd'
    puts "Tag #{hoursagotag} is badfwd, looking forward rather than backward" if (@debug)
    (hoursago-1).downto(0) do |hour|
      hours_to_check << hour
    end
  else
    # Check back up to three days for an acceptable tag.  The three day
    # limit is arbitrary, but we need something so that we avoid going
    # into an infinite loop if there simply isn't an acceptable tag.
    hoursago.upto(24*3) do |hour|
      hours_to_check << hour
    end
  end

  hours_to_check.each do |hour|
    proposedtag = tagforhours(hour)
    puts "Checking tag #{proposedtag} for #{hour} hours ago" if (@debug)
    # If we need a 'good' tag then check that the proposed tag is
    # marked as 'good'.
    if needgoodtag
      if @tagstate[proposedtag] == 'good'
        tag = proposedtag
        puts "Need good tag, proposed tag is good" if (@debug)
        break
      else
        puts "Need good tag, proposed tag is not good" if (@debug)
      end
    else
    # If we don't need a 'good' tag then check that either the
    # proposed tag has no state (unknown, and presumed good in this
    # case), or has a state that isn't 'bad'.
      if @tagstate[proposedtag].nil? || @tagstate[proposedtag] !~ /^bad/
        tag = proposedtag
        puts "Need !bad tag, proposed tag is #{@tagstate[proposedtag]} and thus acceptable" if (@debug)
        break
      else
        puts "Need !bad tag, proposed tag is #{@tagstate[proposedtag]} and thus not acceptable" if (@debug)
      end
    end
  end
  
  if tag.nil?
    abort "No acceptable tag found for hoursago:#{hoursago} and " +
          "needgoodtag:#{needgoodtag}"
  end
  
  tag
end

#
# Grab tag from nVentory
#

nvclient = NVentory::Client.new
results = nvclient.get_objects(:objecttype => 'nodes', :exactget => { 'name' => name }, :includes => ['node_groups'])

tag = nil
DEFAULT_HOURS = 4
hours = DEFAULT_HOURS
if !results.empty? && !results[name].nil?
  if !results[name]['node_groups'].nil?
    node_group_names = results[name]['node_groups'].collect { |ng| ng['name'] }
    case
    when node_group_names.include?('dev') || node_group_names.include?('int')
      hours = 0
    when node_group_names.include?('qa')
      hours = 1
    when node_group_names.include?('stg')
      hours = 2
    end
  end
  
  # For production nodes we want to divide them based on our
  # failover/BCP strategy so that we deploy changes in such a way that
  # a bad change doesn't take out all failover groups at once.  With
  # multiple data centers and global load balancing this could mean
  # deploying to one data center and then the other.  Or you could base
  # it on other node groups.  In other words this section should set about
  # half your machines to hours = 3, and then the remaining systems will
  # get the default number of hours below.
  if hours == DEFAULT_HOURS
    if false  # <-- YOU NEED TO PUT SITE-APPROPRIATE LOGIC HERE
      hours = 3
    end
  end
  
  puts "Based on node groups and location this node gets hours #{hours}" if (@debug)
  
  if !results[name]['config_mgmt_tag'].nil? &&
     !results[name]['config_mgmt_tag'].empty?
    nvtag = results[name]['config_mgmt_tag']
    puts "Tag from nVentory: #{nvtag}" if (@debug)
    # Tags starting with trunk- are used to temporarily bump clients to trunk
    if nvtag =~ /^trunk-(\d{8})-(\d{4})$/
      tagunixtime = convert_tagtime_to_unixtime($1, $2)
      puts "nVentory tag looks like a temporary bump to trunk tag" if (@debug)
      puts "tagunixtime #{tagunixtime}" if (@debug)
      # If the timestamp is less than "hours" old the client gets trunk,
      # otherwise the client gets its normal tag based on "hours".  Ignore
      # timestamps in the future too, so if someone inserts something way in
      # the future the client isn't stuck on trunk forever.
      timelimit = Time.at(Time.now - 60 * 60 * hours)
      puts "timelimit #{timelimit}" if (@debug)
      puts "now #{Time.now}" if (@debug)
      if tagunixtime > timelimit && tagunixtime <= Time.now
        puts "Temporary bump to trunk tag is within acceptable time window" if (@debug)
        tag = 'trunk'
      else
        puts "Temporary bump to trunk tag is outside acceptable time window" if (@debug)
      end
    else
      puts "nVentory tag not a temporary bump to trunk tag, using tag as-is" if (@debug)
      tag = nvtag
    end
  else
    puts "No tag found in nVentory for this node" if (@debug)
  end
end

if tag.nil? || tag.empty?
  tag = File.join('tags', findautotag(hours))
end

puts tag

