lib/gaptool_client.rb in gaptool-client-0.8.0.pre.alpha3 vs lib/gaptool_client.rb in gaptool-client-0.8.0.pre.alpha4

- old
+ new

@@ -1,606 +1,13 @@ #!/usr/bin/env ruby # coding: utf-8 -# rubocop:disable Metrics/LineLength, Lint/Eval +# rubocop:disable Metrics/LineLength -require 'rainbow' -require 'json' require 'clamp' -require 'sshkit' -require 'sshkit/dsl' -require 'set' -require 'gaptool-api' -require 'logger' +require 'gaptool_client/commands' 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) - "#{node['role']}-#{node['environment']}-#{node['instance']}" - 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.query_nodes(opts) - instance = opts[:instance] - role = opts[:role] - environment = opts[:environment] - params = opts[:params] - - if instance - puts Rainbow('Ignoring role and environment as instance is set').red if role || environment - [Gaptool.api.getonenode(instance)] - elsif role && environment - Gaptool.api.getenvroles(role, environment, params) - elsif role - Gaptool.api.getrolenodes(role, params) - elsif environment - Gaptool.api.getenvnodes(environment, params) - else - Gaptool.api.getallnodes(params) - 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))] }] - 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.' - option ['-x', '--exclude-hidden'], :flag, 'Exclude hidden hosts' - parameter 'COMMAND ...', 'Command to run', attribute_name: :commands - - def execute - params = exclude_hidden? ? {} : { hidden: true } - nodes = Gaptool.query_nodes(params.merge(instance: instance, - role: role, environment: 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? - nodes = Gaptool.query_nodes(hidden: true, - instance: instance, - environment: environment, - role: role) - - 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 } : {} - nodes = Gaptool.query_nodes(params.merge(instance: instance, - role: role, - environment: environment)) - 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' - option ['-e', '--environment'], 'ENVIRONMENT', 'Which environment, e.g. production' - option ['-i', '--instance'], 'INSTANCE', 'Instance ID, e.g. i-12345678' - option ['-H', '--hidden'], :flag, 'Include hidden hosts' - 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) - nodes = Gaptool.query_nodes(hidden: hidden? ? true : nil, - role: role, - instance: instance, - environment: environment) - - 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 - Gaptool.error "Instance #{instance} is not in environment #{environment}" - else - app_list.each do |app| - Gaptool.error "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 @@ -611,7 +18,5 @@ 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