#!/usr/bin/env ruby # coding: utf-8 # rubocop:disable Metrics/LineLength, Lint/Eval require 'rainbow' require 'json' require 'clamp' require 'sshkit' require 'sshkit/dsl' require 'set' require 'gaptool-api' require 'logger' module Gaptool START_MARKER = '###### GAPTOOL ######' STOP_MARKER = '###### END GAPTOOL ######' def self.api @api ||= GTAPI::GaptoolServer.new( ENV['GT_USER'], ENV['GT_KEY'], ENV['GT_URL'], ENV['GT_AWS_ZONE'] ) end def self.infohelper(nodes, parseable, grepable, short = false) if parseable && !short puts({ nodes: nodes }.to_json) elsif parseable puts({ nodes: nodes.map { |node| node.select { |k, _v| %w(role environment instance).include?(k) } } }.to_json) else nodes.each do |node| host = "#{node['role']}:#{node['environment']}:#{node['instance']}" if grepable && short puts host elsif !grepable puts Rainbow(host).green end next if short 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' if key == keys.last puts " ┖ #{Rainbow(key).cyan}: #{value}\n\n" else puts " ┠ #{Rainbow(key).cyan}: #{value}" end end end end end end def self.error(message, opts = {}) code = opts[:code] || 1 color = opts[:color] || :red STDERR.puts(Rainbow(message).send(color)) exit code end def self.get_host(node, domain = true) domain = domain ? '.gild.com' : '' "#{node['role']}-#{node['environment']}-#{node['instance']}#{domain}" end def self.ssh_snip_for_node(node) host = Gaptool.get_host(node) <<-EOF # -- #{node['instance']} Host #{host} #{node['instance']} Hostname #{node['hostname']} User #{ENV['GT_USER']} LogLevel FATAL PreferredAuthentications publickey CheckHostIP no StrictHostKeyChecking no UserKnownHostsFile /dev/null # -- end #{node['instance']} EOF end def self.ssh_config config = File.join(Dir.home, '.ssh', 'config') dir = File.dirname(config) parent = File.join(dir, '..') if File.exist?(config) Gaptool.error "#{config}: not writable" unless File.writable?(config) content = File.read(config) # puts Rainbow("Creating backup file at #{config}.bck").green File.open("#{config}.bck", 'w') { |f| f.write(content) } else if !File.exist?(dir) Gaptool.error "Home directory #{parent} does not exists"\ unless Dir.exist?(parent) Dir.mkdir(dir, 0700) elsif !File.directory?(dir) Gaptool.error "#{dir}: not a directory" end content = '' end [config, content] end def self.update_ssh_config_for(nodes, verbose=true) nodes = [nodes] unless nodes.is_a?(Array) config, content = Gaptool.ssh_config nodes.each do |node| snip = Gaptool.ssh_snip_for_node(node) mark = "# -- #{node['instance']}" emark = "# -- end #{node['instance']}" if Regexp.new(mark).match(content) puts Rainbow("Updating ssh config for #{get_host(node)}").green if verbose content.gsub!(/#{mark}.*?#{emark}/m, snip.strip) elsif Regexp.new(Gaptool::STOP_MARKER).match(content) puts Rainbow("Adding ssh config for #{get_host(node)}").green if verbose content.gsub!(/#{Gaptool::STOP_MARKER}/m, snip + "\n" + Gaptool::STOP_MARKER) else puts Rainbow('No gt ssh config found: please run gt ssh-config').yellow puts Rainbow("Adding ssh config for #{get_host(node)}").green if verbose content = <<-EOF #{content} #{Gaptool::START_MARKER} #{snip} #{Gaptool::STOP_MARKER} EOF end end File.open(config, 'w') { |f| f.write(content) } end def self.configure_sshkit SSHKit.config.output_verbosity = Logger::WARN SSHKit::Backend::Netssh.configure do |ssh| ssh.connection_timeout = 30 ssh.ssh_options = { forward_agent: true, global_known_hosts_file: '/dev/null', keys_only: true, port: 22, user: ENV['GT_USER'] } end end def self.remote_exec(nodes, commands, opts = {}) serial = opts[:serial] group_size = opts[:group_size] || 10 if opts[:update_ssh_config].nil? || opts[:update_ssh_config] nodes.each { |n| Gaptool.update_ssh_config_for(n, false) } end pre = opts[:pre_hooks] || [] post = opts[:post_hooks] || [] nodes = Hash[nodes.map { |n| [n['hostname'], n] }] handlers = Hash[nodes.map { |hostname, node| [hostname, InteractionHandler.new(Gaptool.get_host(node, false))] }] opts = { in: :groups, limit: group_size } opts = { in: :sequence } if serial Gaptool.configure_sshkit on(nodes.keys, opts) do |host| pre.each { |h| instance_exec(nodes[host.hostname], &h) } commands.each do |cmd| execute(:bash, "-l -c '#{cmd}'", interaction_handler: handlers[host.hostname]) end post.each { |h| instance_exec(nodes[host.hostname], &h) } end end def self.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 class InteractionHandler attr_reader :host def initialize(host) @host = host end def on_data(_command, stream_name, data, _channel) case stream_name when :stdout puts "#{Rainbow("#{@host}").yellow}> #{data}" when :stderr STDERR.puts "#{Rainbow("#{@host}").red}> #{data}" end end end 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' option ['-a', '--ami'], 'AMI_ID', 'Use a specific AMI for the instance (i.e. ami-xxxxxxx)' option ['-C', '--chef-repo'], 'GITURL', 'git url for the chef repository' option ['-b', '--chef-branch'], 'BRANCH', 'branch of the chef repository to use' 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, 'Add terminate protection' def execute no_terminate = no_terminate? ? true : nil Gaptool.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)' option ['-r', '--role'], 'ROLE', 'Resource name to initilize', required: true option ['-e', '--environment'], 'ENVIRONMENT', 'Which environment, e.g. production', required: true def execute node = Gaptool.api.getonenode(instance) nodes = [node] Gaptool.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 return 0 unless res.downcase == 'yes' puts "Terminating instance #{node['role']}:#{node['environment']}:#{node['instance']} in region #{zone}" begin Gaptool.api.terminatenode(instance, zone) rescue puts Rainbow('Cannot terminate instance').red end end end class RuncmdCommand < Clamp::Command option ['-r', '--role'], 'ROLE', 'Instance role' option ['-e', '--environment'], 'ENVIRONMENT', 'Instance environment' option ['-i', '--instance'], 'INSTANCE', 'Instance id (i-xxxxxxxx)' option ['-s', '--serial'], :flag, 'Run command serially. Order of execution is unknown.' parameter 'COMMAND ...', 'Command to run', attribute_name: :commands def execute Gaptool.error 'Missing instance, environment or role' if !instance && \ !role && \ !environment Gaptool.error 'Missing role' if !role && !instance Gaptool.error 'Missing environment' if !environment && !instance nodes = instance ? [Gaptool.api.getonenode(instance)] : Gaptool.api.getenvroles(role, environment) Gaptool.remote_exec(nodes, [commands.join(' ')], serial: serial?) end end class SSHConfigCommand < Clamp::Command option ['-r', '--remove'], :flag, 'Remove ssh configuration' def execute config, content = Gaptool.ssh_config if remove? data = '' else puts Rainbow('Getting nodes from API').green data = [Gaptool::START_MARKER, '# to remove this block, run gt ssh-config --remove'] Gaptool.api.getallnodes(hidden: true).sort_by { |n| n['instance'] }.each do |node| host = Gaptool.get_host(node) puts " - #{Rainbow(host).blue}" data << Gaptool.ssh_snip_for_node(node) end data << Gaptool::STOP_MARKER data = data.join("\n") end if Regexp.new(Gaptool::START_MARKER).match(content) content.gsub!(/#{Gaptool::START_MARKER}.*?#{Gaptool::STOP_MARKER}/m, data) elsif !remove? content = content + "\n" + data end File.open(config, 'w') { |f| f.write(content) } end end class SSHCommand < Clamp::Command option ['-r', '--role'], 'ROLE', 'Instance role' option ['-e', '--environment'], 'ENVIRONMENT', 'Instance environment' option ['-i', '--instance'], 'INSTANCE', 'Instance id (i-xxxxxxxx)' option ['-f', '--first'], :flag, 'Just connect to first available instance' option ['-t', '--tmux'], :flag, 'No-op, DEPRECATED' def execute puts Rainbow('tmux support has been removed').yellow if tmux? Gaptool.error 'Missing instance, environment or role' if !instance && \ !environment && \ !role Gaptool.error 'Missing role' if !role && !instance Gaptool.error 'Missing environment' if !environment && !instance nodes = instance ? [Gaptool.api.getonenode(instance)] : Gaptool.api.getenvroles(role, environment) if first? || (nodes.length == 1 && !instance) puts Rainbow('No instance specified, but only one instance in cluster or first forced').green node = nodes.first elsif !instance nodes.each_index do |i| puts "#{i}: #{nodes[i]['instance']}" end print Rainbow('Select a node: ').cyan node = nodes[$stdin.gets.chomp.to_i] error 'Invalid selection' if node.nil? else node = nodes.first end Gaptool.update_ssh_config_for(node) system "ssh #{node['instance']}" end end class InfoCommand < Clamp::Command option ['-r', '--role'], 'ROLE', 'Role name, e.g. frontend' option ['-e', '--environment'], 'ENVIRONMENT', 'Which environment, e.g. production' option ['-i', '--instance'], 'INSTANCE', 'Node instance, leave blank to query avilable nodes' option ['-s', '--short'], :flag, 'Only show summary for hosts' 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 = [] params = hidden? ? { hidden: true } : {} if instance nodes = [Gaptool.api.getonenode(instance)] elsif role && environment nodes = Gaptool.api.getenvroles(role, environment, params) elsif role && !environment nodes = Gaptool.api.getrolenodes(role, params) else nodes = Gaptool.api.getallnodes(params) end Gaptool.infohelper(nodes, parseable?, grepable?, short?) 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])] }] Gaptool.infohelper([Gaptool.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' 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) option ['-s', '--serial'], :flag, 'Run command serially. Order of execution is unknown.' option ['-W', '--whyrun'], :flag, 'Whyrun, like dry-run but different.' def execute attrs = Gaptool.split_attrs(attribute_list) if !instance.nil? nodes = [Gaptool.api.getonenode(instance)] else nodes = Gaptool.api.getenvroles(role, environment) end nodes = nodes.map do |x| x['whyrun'] = whyrun? x['chef_branch'] = chef_branch x['attrs'] = attrs x end pre_hook = Proc.new 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' => "#{node['role']}-#{node['environment']}-#{node['instance']}", 'role' => node['role'], 'environment' => node['environment'], 'app_user' => node['appuser'], 'run_list' => runlist, 'hostname' => node['hostname'], 'instance' => node['instance'], 'zone' => node['zone'], 'itype' => node['itype'], 'apps' => eval(node['apps'] || '[]'), 'gaptool' => { 'user' => ENV['GT_USER'], 'key' => ENV['GT_KEY'], 'url' => ENV['GT_URL'] } }.merge(node['attrs']) git = 'sudo -u admin git' pull = "#{git} fetch --all; #{git} reset --hard origin/`#{git} rev-parse --abbrev-ref HEAD`" wopts = node['whyrun'] ? ' -W ' : '' unless node['chef_branch'].nil? json['chefbranch'] = node['chef_branch'] pull = "#{git} checkout -f #{node['chef_branch']}; #{git} fetch --all; #{git} reset --hard origin/#{node['chef_branch']}" end upload!(StringIO.new(json.to_json), '/tmp/chef.json') script = <<-EOS cd /var/data/admin/ops #{pull} sudo chef-solo -c /var/data/admin/ops/cookbooks/solo.rb -j /tmp/chef.json -E #{node['environment']}#{wopts} rm -f /tmp/chef.json EOS upload!(StringIO.new(script), '/tmp/chef.sh') end Gaptool.remote_exec(nodes, ['chmod +x /tmp/chef.sh', '/tmp/chef.sh', 'rm -f /tmp/chef.sh'], pre_hooks: [pre_hook], serial: serial?) 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' 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.' 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' option ['-s', '--serial'], :flag, 'Run command serially. Order of execution is unknown.' def execute # rubocop:disable Metrics/MethodLength attrs = Gaptool.split_attrs(attribute_list) if instance n = Gaptool.api.getonenode(instance) if n['environment'] != environment abort "Instance #{instance} is not in environment #{environment}" else app_list.each do |app| abort "Instance #{instance} does not host #{app} in env #{environment}" \ unless n['apps'].include?(app) end end nodes = [n] else params = hidden? ? { hidden: true } : {} nodes = [] app_list.each do |app| nodes.concat(Gaptool.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 nodes = nodes.map do |x| x['apps'] = eval(x['apps']) x['apps_to_deploy'] = (Set.new(x['apps']) & app_set).to_a x['rollback'] = rollback? x['branch'] = branch || 'master' x['migrate'] = migrate? x['attrs'] = attrs x end pre_hook = Proc.new 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']}-#{node['environment']}-#{node['instance']}", 'role' => node['role'], 'environment' => node['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' => node['rollback'], 'branch' => node['branch'], 'migrate' => node['migrate'], 'gaptool' => { 'user' => ENV['GT_USER'], 'key' => ENV['GT_KEY'], 'url' => ENV['GT_URL'] } }.merge(node['attrs']).to_json upload!(StringIO.new(json), '/tmp/chef.json') script = <<-EOS cd /var/data/admin/ops sudo -u admin git pull sudo chef-solo -c /var/data/admin/ops/cookbooks/solo.rb -j /tmp/chef.json -E #{node['environment']} rm -f /tmp/chef.json EOS upload!(StringIO.new(script), '/tmp/deploy.sh') end Gaptool.remote_exec(nodes, ['chmod +x /tmp/deploy.sh', '/tmp/deploy.sh', 'rm -f /tmp/deploy.sh'], pre_hooks: [pre_hook], serial: serial?) end end class RehashCommand < Clamp::Command option ['-y', '--yes'], :flag, 'YES I REALLY WANT TO DO THIS' def execute if yes? puts Gaptool.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 #{Gaptool.api.version}" return 0 unless remote? vinfo = Gaptool.api.api_version puts "gaptool-server #{vinfo['server_version']}" 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-config', 'Configure ssh', SSHConfigCommand 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