#!/usr/bin/env ruby
# coding: utf-8
require 'rainbow'
require 'peach'
require 'json'
require 'clamp'
require 'net/ssh'
require 'net/scp'
require 'set'

def infohelper(nodes, parseable, grepable)
  if parseable
    puts nodes.to_json
  else
    nodes.each do |node|
      host = "#{node['role']}:#{node['environment']}:#{node['instance']}"
      unless grepable
        puts Rainbow(host).green
      end
      keys = node.keys.sort
      keys.each do |key|
        value = node[key]
        if grepable
          puts "#{host}|#{key}|#{value}"
        else
          value = Time.at(node[key].to_i) if key == 'launch_time'
          unless key == keys.last
            puts "  ┠  #{Rainbow(key).cyan}: #{value}"
          else
            puts "  ┖  #{Rainbow(key).cyan}: #{value}\n\n"
          end
        end
      end
    end
  end
end

def sshcmd(node, commands)
  Net::SSH.start(
    node['hostname'],
    'admin',
    :key_data => [$api.ssh(node['role'], node['environment'], node['instance'])['key']],
    :config => false,
    :keys_only => true,
    :paranoid => false
  ) do |ssh|
    exit_code = nil
    exit_signal = nil
    commands.each do |command|
      ssh.open_channel do |channel|
        channel.exec(command) do |chx, success|
          unless success
            abort "FAILED: couldn't execute command (ssh.channel.exec)"
          end
          channel.on_data do |chd,data|
            puts "#{Rainbow(node['role']).yellow}:#{Rainbow(node['environment']).yellow}:#{Rainbow(node['instance']).yellow}> #{data}"
          end

          channel.on_extended_data do |che,type,data|
            puts "#{Rainbow(node['role']).yellow}:#{Rainbow(node['environment']).yellow}:#{Rainbow(node['instance']).red}> #{data}"
          end

          channel.on_request("exit-status") do |chs,data|
            exit_code = data.read_long
            if exit_code != 0
              exit exit_code
            end
          end

          channel.on_request("exit-signal") do |chg, data|
            exit_signal = data.read_string
          end
        end
      end
    end
  end
end

def split_attrs(attribute_list)
  opts = {}
  attribute_list.each do |attr_|
    key, value = attr_.split('=', 2)
    split = key.split('.')
    cur = opts
    split.each_with_index do |part, idx|
      if idx == split.size - 1
        # leaf, add the value
        cur[part] = value
      else
        cur[part] ||= {}
      end
      cur = cur[part]
    end
  end
  opts
end

module Gaptool
  class InitCommand < Clamp::Command
    option ["-r", "--role"], "ROLE", "Resource name to initilize", :required => true
    option ["-e", "--environment"], "ENVIRONMENT", "Which environment, e.g. production", :required => true
    option ["-z", "--zone"], "ZONE", "AWS availability zone to put node in", :default => 'us-west-2c'
    option ["-t", "--type"], "TYPE", "Type of instance, e.g. m1.large", :required => true
    option ["-s", "--security-group"], "SECURITY_GROUP", "Security group name. Defaults to $role-$environment", required: false
    option ['-a', '--ami'], "AMI_ID", "Use a specific AMI for the instance (i.e. ami-xxxxxxx)", :required => false
    option ["-C", "--chef-repo"], "GITURL", "git url for the chef repository", :requireds => false
    option ["-b", "--chef-branch"], "BRANCH", "branch of the chef repository to use", :required => false
    option ['-R', "--chef_runlist"], 'RECIPE|ROLE', 'override chef run_list. recipe[cb::recipe] or role[myrole]. Can be specified multiple times', :multivalued => true, :attribute_name => 'chef_runlist'
    option ['--no-terminate'], :flag, "Protect this instance from being terminated"
    def execute
      no_terminate = no_terminate? ? true : nil
      $api.addnode(zone, type, role, environment, nil,
                   security_group, ami, chef_repo, chef_branch, chef_runlist,
                   no_terminate)
    end
  end

  class TerminateCommand < Clamp::Command
    option ["-i", "--instance"], "INSTANCE", "Instance ID, e.g. i-12345678", :required => true
    option ["-z", "--zone"], "ZONE", "AWS region of the node (deprecated/ignored)", :default => nil
    option ["-r", "--role"], "ROLE", "Resource name to initilize", :required => true
    option ["-e", "--environment"], "ENVIRONMENT", "Which environment, e.g. production", :required => true
    def execute
      node = $api.getonenode(instance)
      nodes = [node]
      infohelper(nodes, false, false)
      zone = node['zone'][0..-2]
      if node['environment'] != environment || node['role'] != role
        puts Rainbow("'#{node['role']}-#{node['environment']}' do not match provided value (#{role}-#{environment})").red
        exit 1
      end
      if node['terminate'] == 'false'
        puts Rainbow('"terminate" command is disabled for this instance').red
        exit 2
      end
      print Rainbow("Terminate instance? [type yes to confirm]: ").green
      res = $stdin.gets.chomp
      if res.downcase == 'yes'
        puts "Terminating instance #{node['role']}:#{node['environment']}:#{node['instance']} in region #{zone}"
        begin
          res = $api.terminatenode(instance, zone)
        rescue
          puts Rainbow('Cannot terminate instance').red
        end
      end
    end
  end

  class RuncmdCommand < Clamp::Command
    option ["-r", "--role"], "ROLE", "Role name to ssh to", :required => true
    option ["-e", "--environment"], "ENVIRONMENT", "Which environment, e.g. production", :required => true
    option ["-i", "--instance"], "INSTANCE", "Instance ID, e.g. i-12345678", :required => false
    parameter "COMMAND ...", "Command to run", :attribute_name => :command
    def execute
      if !instance.nil?
        node = $api.getonenode(instance)
        nodes = [$api.getonenode(instance)]
      else
        nodes = $api.getenvroles(role, environment)
      end
      nodes.peach do |node|
        commands = [ command.join(' ') ]
        sshcmd(node, commands)
      end
    end
  end

  class SshCommand < Clamp::Command
    option ["-r", "--role"], "ROLE", "Role name to ssh to", :required => true
    option ["-e", "--environment"], "ENVIRONMENT", "Which environment, e.g. production", :required => true
    option ["-i", "--instance"], "INSTANCE", "Node instance, leave blank to query avilable nodes", :require => false
    option ["-f", "--first"], :flag, "Just connect to first available instance"
    option ["-t", "--tmux"], :flag, "Open cluster in windows in a tmux session"

    def execute
      if tmux?
        nodes = $api.getenvroles(role, environment)
        system "tmux start-server"
        nodes.each_index do |i|
          @ssh = $api.ssh(role, environment, nodes[i]['instance'])
          if i == 0
            system "tmux new-session -d -s #{role}-#{environment} -n #{nodes[i]['instance']}"
          else
            system "tmux new-window -t #{role}-#{environment}:#{i} -n #{nodes[i]['instance']}"
          end
          File.open("/tmp/gtkey-#{nodes[i]['instance']}", 'w') {|f| f.write(@ssh['key'])}
          File.chmod(0600, "/tmp/gtkey-#{nodes[i]['instance']}")
          system "tmux send-keys -t #{role}-#{environment}:#{i} 'SSH_AUTH_SOCK=\"\" ssh -i /tmp/gtkey-#{nodes[i]['instance']} admin@#{@ssh['hostname']}' C-m"
        end
        system "tmux attach -t #{role}-#{environment}"
      else
        if instance
          @ssh = $api.ssh(role, environment, instance)
        else
          nodes = $api.getenvroles(role, environment)
          if first? || nodes.size == 1
            puts "No instance specified, but only one instance in cluster or first forced"
            @ssh = $api.ssh(role, environment, nodes.first['instance'])
          else
            puts "No instance specified, querying list."
            nodes.each_index do |i|
              puts "#{i}: #{nodes[i]['instance']}"
            end
            print Rainbow("Select a node: ").cyan
            @ssh = $api.ssh(role, environment, nodes[$stdin.gets.chomp.to_i]['instance'])
          end
        end
        File.open('/tmp/gtkey', 'w') {|f| f.write(@ssh['key'])}
        File.chmod(0600, '/tmp/gtkey')
        system "SSH_AUTH_SOCK='' ssh -i /tmp/gtkey admin@#{@ssh['hostname']}"
      end
    end

  end

  class InfoCommand < Clamp::Command
    option ["-r", "--role"], "ROLE", "Role name, e.g. frontend", :required => false
    option ["-e", "--environment"], "ENVIRONMENT", "Which environment, e.g. production", :required => false
    option ["-i", "--instance"], "INSTANCE", "Node instance, leave blank to query avilable nodes", :required => false
    option ["-p", "--parseable"], :flag, "Display in non-pretty parseable JSON"
    option ["-g", "--grepable"], :flag, "Display in non-pretty grep-friendly text"
    option ['-H', '--hidden'], :flag, 'Display hidden hosts'

    def execute
      @nodes = Array.new
      params = hidden? ? {hidden: true} : {}
      if instance
        @nodes = [$api.getonenode(instance)]
      elsif role && environment
        @nodes = $api.getenvroles(role, environment, params)
      elsif role && !environment
        @nodes = $api.getrolenodes(role, params)
      else
        @nodes = $api.getallnodes(params)
      end
      infohelper(@nodes, parseable?, grepable?)
    end
  end

  class SetCommand < Clamp::Command
    option ["-i", "--instance"], "INSTANCE", "Node instance, required", required: true
    option ["-p", "--parseable"], :flag, "Display in non-pretty parseable JSON"
    option ["-g", "--grepable"], :flag, "Display in non-pretty grep-friendly text"
    option ['-k', '--parameter'], 'NAME', 'Set parameter for the node', required: true, multivalued: true
    option ['-v', '--value'], 'VALUE', 'Value for parameter', required: true, multivalued: true

    def convert_bool(v)
      case v.downcase
      when "true"
        true
      when "false"
        false
      else
        v
      end
    end

    def execute
      if parameter_list.length != value_list.length
        puts Rainbow("parameter and value length mismatch").red
      end
      params = Hash[parameter_list.each_with_index.map {|p, i| [p, convert_bool(value_list[i])]}]
      infohelper([$api.setparameters(instance, params)], parseable?, grepable?)
    end
  end

  class ChefrunCommand < Clamp::Command
    option ["-r", "--role"], "ROLE", "Role name to ssh to", :required => true
    option ["-e", "--environment"], "ENVIRONMENT", "Which environment, e.g. production", :required => true
    option ["-i", "--instance"], "INSTANCE", "Instance ID, e.g. i-12345678", :required => false
    option ["-A", "--attribute"], "ATTRIBUTE", "Pass one or more parameters to the deploy recipe in recipe.attr=value format", :multivalued => true
    option ["-b", "--chef-branch"], "BRANCH", "branch of the chef repository to use (defaults to last branch used during init/chefrun)", :default => nil

    def execute
      attrs = split_attrs(attribute_list)
      if !instance.nil?
        nodes = [$api.getonenode(instance)]
      else
        nodes = $api.getenvroles(role, environment)
      end
      nodes.peach do |node|
        if node['chef_runlist'].nil?
          runlist = ['recipe[main]']
        elsif node['chef_runlist'].is_a? Array
          runlist = node['chef_runlist']
        else
          runlist = eval(node['chef_runlist'])
        end
        json = {
          'this_server' => "#{role}-#{environment}-#{node['instance']}",
          'role' => role,
          'environment' => environment,
          'app_user' => node['appuser'],
          'run_list' => runlist,
          'hostname' => node['hostname'],
          'instance' => node['instance'],
          'zone' => node['zone'],
          'itype' => node['itype'],
          'apps' => eval(node['apps'] || '[]'),
        }.merge(attrs)
        git="git fetch --all; git reset --hard origin/`git rev-parse --abbrev-ref HEAD`"

        unless chef_branch.nil?
          json['chefbranch'] = chef_branch
          git = "git checkout -f #{chef_branch}; git fetch --all; git reset --hard origin/#{chef_branch}"
        end
        commands = [
          "cd ~admin/ops; #{git} ",
          "echo '#{json.to_json}' > ~admin/solo.json",
          "sudo chef-solo -c ~admin/ops/cookbooks/solo.rb -j ~admin/solo.json -E #{environment}"
        ]
        sshcmd(node, commands)
      end
    end
  end

  class DeployCommand < Clamp::Command
    option ["-a", "--app"], "APP", "Application(s) to deploy (can be set multiple times)", :required => true, :multivalued => true
    option ["-m", "--migrate"], :flag, "Toggle running migrations"
    option ["-e", "--environment"], "ENVIRONMENT", "Which environment, e.g. production", :required => true
    option ["-b", "--branch"], "BRANCH", "Git branch to deploy, default is master", :required => false
    option ["-r", "--rollback"], :flag, "Toggle this to rollback last deploy"
    option ["-i", "--instance"], "INSTANCE", "Instance ID, e.g. i-12345678. If set, all applications MUST be hosted on this node.", :required => false
    option ["-A", "--attribute"], "ATTRIBUTE", "Pass one or more parameters to the deploy recipe in recipe.attr=value format", :multivalued => true
    option ['-H', '--hidden'], :flag, 'Display hidden hosts'

    def execute
      attrs = split_attrs(attribute_list)
      if instance
        n = $api.getonenode(instance)
        if n['environment'] != environment
          abort "Instance #{instance} is not in environment #{environment}"
        else
          app_list.each do |app|
            if !n['apps'].include?(app)
              abort "Instance #{instance} does not host #{app} in env #{environment}"
            end
          end
        end
        nodes = [n]

      else
        params = hidden? ? {hidden: true} : {}
        nodes = []
        app_list.each do |app|
          nodes.concat($api.getappnodes(app, environment, params))
        end
      end

      # dedup nodes
      seen = Set.new
      app_set = Set.new(app_list)
      nodes = nodes.select do |x|
        res = !seen.include?(x['instance']);
        seen << x['instance'];
        res
      end.map do |x|
        x['apps'] = eval(x['apps'])
        x['apps_to_deploy'] = (Set.new(x['apps']) & app_set).to_a
        x
      end

      nodes.peach do |node|
        host = "#{node['role']}:#{node['environment']}:#{node['instance']}"
        puts "#{Rainbow("Deploying apps").cyan} '" + \
              Rainbow(node['apps_to_deploy'].join(" ")).green + \
              "' #{Rainbow("on").cyan} " + \
              Rainbow(host).green

        if node['chef_runlist'].nil?
          runlist = ['recipe[deploy]']
        elsif node['chef_runlist'].is_a? Array
          runlist = node['chef_runlist']
        else
          runlist = eval(node['chef_runlist'])
        end
        json = {
          'this_server' => "#{node['role']}-#{environment}-#{node['instance']}",
          'role' => node['role'],
          'environment' => environment,
          'app_user' => node['appuser'],
          'run_list' => runlist,
          'hostname' => node['hostname'],
          'instance' => node['instance'],
          'zone' => node['zone'],
          'itype' => node['itype'],
          'apps' => node['apps'],
          'deploy_apps' => node['apps_to_deploy'],
          'rollback' => rollback?,
          'branch' => branch || 'master',
          'migrate' => migrate?
        }.merge(attrs).to_json
        commands = [
          "cd ~admin/ops; git pull",
          "echo '#{json}' > ~admin/solo.json",
          "sudo chef-solo -c ~admin/ops/cookbooks/solo.rb -j ~admin/solo.json -E #{environment}"
        ]
        sshcmd(node, commands)
      end
    end
  end

  class RehashCommand < Clamp::Command
    option ["-y", "--yes"], :flag, "YES I REALLY WANT TO DO THIS"
    def execute
      if yes?
        puts $api.rehash()
      else
        puts "You need to run this with -y\nIf you don't know what this does or aren't sure, DO NOT RUN IT\nThis will regenerate all host metadata on gaptool-server\nand can break in-progress operations."
      end
    end
  end

  class VersionCommand < Clamp::Command
    option ["-r", "--remote"], :flag, "Include remote API version"
    def execute
      version = File.read(File.realpath(File.join(File.dirname(__FILE__), "..", 'VERSION'))).strip
      puts "gaptool-client #{version} using gaptool-api #{$api.version}"
      if remote?
        vinfo = $api.api_version()
        puts "gaptool-server #{vinfo['server_version']}"
      end
    end
  end

  class MainCommand < Clamp::Command

    subcommand "info", "Displays information about nodes", InfoCommand
    subcommand "init", "Create new application cluster", InitCommand
    subcommand "terminate", "Terminate instance", TerminateCommand
    subcommand "ssh", "ssh to cluster host", SshCommand
    subcommand "set", "update properties for a node", SetCommand
    subcommand "chefrun", "chefrun on a resource pool", ChefrunCommand
    subcommand "deploy", "deploy on an application", DeployCommand
    subcommand "rehash", "Regenerate all host metadata. KNOW WHAT THIS DOES BEFORE RUNNING IT", RehashCommand
    subcommand "runcmd", "Run command on instance", RuncmdCommand
    subcommand "version", "Show version", VersionCommand

  end
end

Gaptool::MainCommand.run