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