From c77cace5225eb16d35865664754e88f4d67bba7f Mon Sep 17 00:00:00 2001 From: elijah Date: Thu, 23 Jun 2016 15:49:03 -0700 Subject: migrate commands to use new ssh system: node init, test, add-user --- lib/leap_cli/commands/deploy.rb | 201 ++++++++++++++++++++----------------- lib/leap_cli/commands/facts.rb | 13 ++- lib/leap_cli/commands/info.rb | 15 ++- lib/leap_cli/commands/node_init.rb | 87 ++++++++-------- lib/leap_cli/commands/run.rb | 47 +++++++++ lib/leap_cli/commands/ssh.rb | 14 --- lib/leap_cli/commands/test.rb | 41 ++++---- lib/leap_cli/commands/user.rb | 73 ++++++++------ lib/leap_cli/commands/util.rb | 1 - lib/leap_cli/ssh.rb | 7 ++ lib/leap_cli/ssh/backend.rb | 154 ++++++++++++++++++++++++++++ lib/leap_cli/ssh/formatter.rb | 75 ++++++++++++++ lib/leap_cli/ssh/key.rb | 199 ++++++++++++++++++++++++++++++++++++ lib/leap_cli/ssh/options.rb | 93 +++++++++++++++++ lib/leap_cli/ssh/remote_command.rb | 107 ++++++++++++++++++++ lib/leap_cli/ssh/scripts.rb | 136 +++++++++++++++++++++++++ 16 files changed, 1054 insertions(+), 209 deletions(-) create mode 100644 lib/leap_cli/commands/run.rb create mode 100644 lib/leap_cli/ssh.rb create mode 100644 lib/leap_cli/ssh/backend.rb create mode 100644 lib/leap_cli/ssh/formatter.rb create mode 100644 lib/leap_cli/ssh/key.rb create mode 100644 lib/leap_cli/ssh/options.rb create mode 100644 lib/leap_cli/ssh/remote_command.rb create mode 100644 lib/leap_cli/ssh/scripts.rb diff --git a/lib/leap_cli/commands/deploy.rb b/lib/leap_cli/commands/deploy.rb index 9dd190ab..165ce588 100644 --- a/lib/leap_cli/commands/deploy.rb +++ b/lib/leap_cli/commands/deploy.rb @@ -29,57 +29,7 @@ module LeapCli :arg_name => 'IPADDRESS' c.action do |global,options,args| - - if options[:dev] != true - init_submodules - end - - nodes = manager.filter!(args, :disabled => false) - if nodes.size > 1 - say "Deploying to these nodes: #{nodes.keys.join(', ')}" - if !global[:yes] && !agree("Continue? ") - quit! "OK. Bye." - end - end - - environments = nodes.field('environment').uniq - if environments.empty? - environments = [nil] - end - environments.each do |env| - check_platform_pinning(env, global) - end - - # compile hiera files for all the nodes in every environment that is - # being deployed and only those environments. - compile_hiera_files(manager.filter(environments), false) - - ssh_connect(nodes, connect_options(options)) do |ssh| - ssh.leap.log :checking, 'node' do - ssh.leap.check_for_no_deploy - ssh.leap.assert_initialized - end - ssh.leap.log :synching, "configuration files" do - sync_hiera_config(ssh) - sync_support_files(ssh) - end - ssh.leap.log :synching, "puppet manifests" do - sync_puppet_files(ssh) - end - unless options[:sync] - ssh.leap.log :applying, "puppet" do - ssh.puppet.apply(:verbosity => [LeapCli.log_level,5].min, - :tags => tags(options), - :force => options[:force], - :info => deploy_info, - :downgrade => options[:downgrade] - ) - end - end - end - if !Util.exit_status.nil? && Util.exit_status != 0 - log :warning, "puppet did not finish successfully." - end + run_deploy(global, options, args) end end @@ -94,19 +44,87 @@ module LeapCli c.switch :last, :desc => 'Show last deploy only', :negatable => false c.action do |global,options,args| - if options[:last] == true - lines = 1 - else - lines = 10 + run_history(global, options, args) + end + end + + private + + def run_deploy(global, options, args) + require 'leap_cli/ssh' + + if options[:dev] != true + init_submodules + end + + nodes = manager.filter!(args, :disabled => false) + if nodes.size > 1 + say "Deploying to these nodes: #{nodes.keys.join(', ')}" + if !global[:yes] && !agree("Continue? ") + quit! "OK. Bye." end - nodes = manager.filter!(args) - ssh_connect(nodes, connect_options(options)) do |ssh| - ssh.leap.history(lines) + end + + environments = nodes.field('environment').uniq + if environments.empty? + environments = [nil] + end + environments.each do |env| + check_platform_pinning(env, global) + end + + # compile hiera files for all the nodes in every environment that is + # being deployed and only those environments. + compile_hiera_files(manager.filter(environments), false) + + log :checking, 'nodes' do + SSH.remote_command(nodes, options) do |ssh, host| + begin + ssh.scripts.check_for_no_deploy + ssh.scripts.assert_initialized + rescue SSH::ExecuteError + # skip nodes with errors, but run others + nodes.delete(host.hostname) + end + end + end + + log :synching, "configuration files" do + sync_hiera_config(nodes, options) + sync_support_files(nodes, options) + end + log :synching, "puppet manifests" do + sync_puppet_files(nodes, options) + end + + unless options[:sync] + log :applying, "puppet" do + SSH.remote_command(nodes, options) do |ssh, host| + ssh.scripts.puppet_apply( + :verbosity => [LeapCli.log_level,5].min, + :tags => tags(options), + :force => options[:force], + :info => deploy_info, + :downgrade => options[:downgrade] + ) + end end end end - private + def run_history(global, options, args) + require 'leap_cli/ssh' + + if options[:last] == true + lines = 1 + else + lines = 10 + end + nodes = manager.filter!(args) + SSH.remote_command(nodes, options) do |ssh, host| + ssh.scripts.history(lines) + end + end def forcible_prompt(forced, msg, prompt) say(msg) @@ -211,56 +229,51 @@ module LeapCli end end - def sync_hiera_config(ssh) - ssh.rsync.update do |server| - node = manager.node(server.host) + def sync_hiera_config(nodes, options) + SSH.remote_sync(nodes, options) do |sync, host| + node = manager.node(host.hostname) hiera_file = Path.relative_path([:hiera, node.name]) - ssh.leap.log hiera_file + ' -> ' + node.name + ':' + Leap::Platform.hiera_path - { - :source => hiera_file, - :dest => Leap::Platform.hiera_path, - :flags => "-rltp --chmod=u+rX,go-rwx" - } + sync.log hiera_file + ' -> ' + node.name + ':' + Leap::Platform.hiera_path + sync.source = hiera_file + sync.dest = Leap::Platform.hiera_path + sync.flags = "-rltp --chmod=u+rX,go-rwx" + sync.exec end end # # sync various support files. # - def sync_support_files(ssh) - dest_dir = Leap::Platform.files_dir + def sync_support_files(nodes, options) + dest_dir = Leap::Platform.files_dir custom_files = build_custom_file_list - ssh.rsync.update do |server| - node = manager.node(server.host) + SSH.remote_sync(nodes, options) do |sync, host| + node = manager.node(host.hostname) files_to_sync = node.file_paths.collect {|path| Path.relative_path(path, Path.provider) } files_to_sync += custom_files if files_to_sync.any? - ssh.leap.log(files_to_sync.join(', ') + ' -> ' + node.name + ':' + dest_dir) - { - :chdir => Path.named_path(:files_dir), - :source => ".", - :dest => dest_dir, - :excludes => "*", - :includes => calculate_includes_from_files(files_to_sync, '/files'), - :flags => "-rltp --chmod=u+rX,go-rwx --relative --delete --delete-excluded --copy-links" - } - else - nil + sync.log(files_to_sync.join(', ') + ' -> ' + node.name + ':' + dest_dir) + sync.chdir = Path.named_path(:files_dir) + sync.source = "." + sync.dest = dest_dir + sync.excludes = "*" + sync.includes = calculate_includes_from_files(files_to_sync, '/files') + sync.flags = "-rltp --chmod=u+rX,go-rwx --relative --delete --delete-excluded --copy-links" + sync.exec end end end - def sync_puppet_files(ssh) - ssh.rsync.update do |server| - ssh.leap.log(Path.platform + '/[bin,tests,puppet] -> ' + server.host + ':' + Leap::Platform.leap_dir) - { - :dest => Leap::Platform.leap_dir, - :source => '.', - :chdir => Path.platform, - :excludes => '*', - :includes => ['/bin', '/bin/**', '/puppet', '/puppet/**', '/tests', '/tests/**'], - :flags => "-rlt --relative --delete --copy-links" - } + def sync_puppet_files(nodes, options) + SSH.remote_sync(nodes, options) do |sync, host| + sync.log(Path.platform + '/[bin,tests,puppet] -> ' + host.hostname + ':' + Leap::Platform.leap_dir) + sync.dest = Leap::Platform.leap_dir + sync.source = '.' + sync.chdir = Path.platform + sync.excludes = '*' + sync.includes = ['/bin', '/bin/**', '/puppet', '/puppet/**', '/tests', '/tests/**'] + sync.flags = "-rlt --relative --delete --copy-links" + sync.exec end end diff --git a/lib/leap_cli/commands/facts.rb b/lib/leap_cli/commands/facts.rb index 11329ccc..6c954ee8 100644 --- a/lib/leap_cli/commands/facts.rb +++ b/lib/leap_cli/commands/facts.rb @@ -79,15 +79,18 @@ module LeapCli; module Commands private def update_facts(global_options, options, args) + require 'leap_cli/ssh' nodes = manager.filter(args, :local => false, :disabled => false) new_facts = {} - ssh_connect(nodes) do |ssh| - ssh.leap.run_with_progress(facter_cmd) do |response| - node = manager.node(response[:host]) + SSH.remote_command(nodes) do |ssh, host| + response = ssh.capture(facter_cmd, :log_output => false) + if response + log 'done', :host => host + node = manager.node(host) if node - new_facts[node.name] = response[:data].strip + new_facts[node.name] = response.strip else - log :warning, 'Could not find node for hostname %s' % response[:host] + log :warning, 'Could not find node for hostname %s' % host end end end diff --git a/lib/leap_cli/commands/info.rb b/lib/leap_cli/commands/info.rb index 52225a94..a49c20c9 100644 --- a/lib/leap_cli/commands/info.rb +++ b/lib/leap_cli/commands/info.rb @@ -5,10 +5,17 @@ module LeapCli; module Commands arg_name 'FILTER' command [:info] do |c| c.action do |global,options,args| - nodes = manager.filter!(args) - ssh_connect(nodes, connect_options(options)) do |ssh| - ssh.leap.debug - end + run_info(global, options, args) + end + end + + private + + def run_info(global, options, args) + require 'leap_cli/ssh' + nodes = manager.filter!(args) + SSH.remote_command(nodes, options) do |ssh, host| + ssh.scripts.debug end end diff --git a/lib/leap_cli/commands/node_init.rb b/lib/leap_cli/commands/node_init.rb index 9698a789..62a57496 100644 --- a/lib/leap_cli/commands/node_init.rb +++ b/lib/leap_cli/commands/node_init.rb @@ -14,50 +14,55 @@ module LeapCli; module Commands "This command only needs to be run once, but there is no harm in running it multiple times." cmd.arg_name 'FILTER' cmd.command :init do |init| - init.switch 'echo', :desc => 'If set, passwords are visible as you type them (default is hidden)', :negatable => false + #init.switch 'echo', :desc => 'If set, passwords are visible as you type them (default is hidden)', :negatable => false + # ^^ i am not sure how to get this working with sshkit init.flag :port, :desc => 'Override the default SSH port.', :arg_name => 'PORT' init.flag :ip, :desc => 'Override the default SSH IP address.', :arg_name => 'IPADDRESS' init.action do |global,options,args| - assert! args.any?, 'You must specify a FILTER' - finished = [] - manager.filter!(args).each_node do |node| - is_node_alive(node, options) - save_public_host_key(node, global, options) unless node.vagrant? - update_compiled_ssh_configs - ssh_connect_options = connect_options(options).merge({:bootstrap => true, :echo => options[:echo]}) - ssh_connect(node, ssh_connect_options) do |ssh| - if node.vagrant? - ssh.install_insecure_vagrant_key - end - ssh.install_authorized_keys - ssh.install_prerequisites - unless node.vagrant? - ssh.leap.log(:checking, "SSH host keys") do - ssh.leap.capture(get_ssh_keys_cmd) do |response| - update_local_ssh_host_keys(node, response[:data]) if response[:exitcode] == 0 - end - end - end - ssh.leap.log(:updating, "facts") do - ssh.leap.capture(facter_cmd) do |response| - if response[:exitcode] == 0 - update_node_facts(node.name, response[:data]) - else - log :failed, "to run facter on #{node.name}" - end - end + run_node_init(global, options, args) + end + end + end + + private + + def run_node_init(global, options, args) + require 'leap_cli/ssh' + assert! args.any?, 'You must specify a FILTER' + finished = [] + manager.filter!(args).each_node do |node| + is_node_alive(node, options) + save_public_host_key(node, global, options) unless node.vagrant? + update_compiled_ssh_configs + # allow password auth for new nodes: + options[:auth_methods] = ["publickey", "password"] + SSH.remote_command(node, options) do |ssh, host| + if node.vagrant? + ssh.scripts.install_insecure_vagrant_key + end + ssh.scripts.install_authorized_keys + ssh.scripts.install_prerequisites + unless node.vagrant? + ssh.log(:checking, "SSH host keys") do + response = ssh.capture(get_ssh_keys_cmd, :log_output => false) + if response + update_local_ssh_host_keys(node, response) end end - finished << node.name end - log :completed, "initialization of nodes #{finished.join(', ')}" + ssh.log(:updating, "facts") do + response = ssh.capture(facter_cmd) + if response + update_node_facts(node.name, response) + end + end end + finished << node.name end + log :completed, "initialization of nodes #{finished.join(', ')}" end - private - ## ## PRIVATE HELPERS ## @@ -83,7 +88,7 @@ module LeapCli; module Commands pub_key_path = Path.named_path([:node_ssh_pub_key, node.name]) if Path.exists?(pub_key_path) - if host_keys.include? SshKey.load(pub_key_path) + if host_keys.include? SSH::Key.load(pub_key_path) log :trusted, "- Public SSH host key for #{node.name} matches previously saved key", :indent => 1 else bail! do @@ -96,7 +101,7 @@ module LeapCli; module Commands if known_key log :trusted, "- Public SSH host key for #{node.name} is trusted (key found in your ~/.ssh/known_hosts)" else - public_key = SshKey.pick_best_key(host_keys) + public_key = SSH::Key.pick_best_key(host_keys) if public_key.nil? bail!("We got back #{host_keys.size} host keys from #{node.name}, but we can't support any of them.") else @@ -118,7 +123,7 @@ module LeapCli; module Commands # # Get the public host keys for a host using ssh-keyscan. - # Return an array of SshKey objects, one for each key. + # Return an array of SSH::Key objects, one for each key. # def get_public_keys_for_ip(address, port=22) assert_bin!('ssh-keyscan') @@ -130,7 +135,7 @@ module LeapCli; module Commands if output =~ /No route to host/ bail! :failed, 'ssh-keyscan: no route to %s' % address else - keys = SshKey.parse_keys(output) + keys = SSH::Key.parse_keys(output) if keys.empty? bail! "ssh-keyscan got zero host keys back (that we understand)! Output was: #{output}" else @@ -139,7 +144,7 @@ module LeapCli; module Commands end end - # run on the server to generate a string suitable for passing to SshKey.parse_keys() + # run on the server to generate a string suitable for passing to SSH::Key.parse_keys() def get_ssh_keys_cmd "/bin/grep ^HostKey /etc/ssh/sshd_config | /usr/bin/awk '{print $2 \".pub\"}' | /usr/bin/xargs /bin/cat" end @@ -149,10 +154,10 @@ module LeapCli; module Commands # stored locally. In these cases, ask the user if they want to upgrade. # def update_local_ssh_host_keys(node, remote_keys_string) - remote_keys = SshKey.parse_keys(remote_keys_string) + remote_keys = SSH::Key.parse_keys(remote_keys_string) return unless remote_keys.any? - current_key = SshKey.load(Path.named_path([:node_ssh_pub_key, node.name])) - best_key = SshKey.pick_best_key(remote_keys) + current_key = SSH::Key.load(Path.named_path([:node_ssh_pub_key, node.name])) + best_key = SSH::Key.pick_best_key(remote_keys) return unless best_key && current_key if current_key != best_key say(" One of the SSH host keys for node '#{node.name}' is better than what you currently have trusted.") diff --git a/lib/leap_cli/commands/run.rb b/lib/leap_cli/commands/run.rb new file mode 100644 index 00000000..52121035 --- /dev/null +++ b/lib/leap_cli/commands/run.rb @@ -0,0 +1,47 @@ +module LeapCli; module Commands + + desc 'runs the specified command on each node.' + arg_name 'FILTER' + command :run do |c| + c.flag 'cmd', :arg_name => 'COMMAND', :desc => 'The command to run.' + c.switch 'stream', :default => false, :desc => 'If set, stream the output as it arrives. (default: --no-stream)' + c.flag 'port', :arg_name => 'SSH_PORT', :desc => 'Override default SSH port used when trying to connect to the server.' + c.action do |global, options, args| + run_shell_command(global, options, args) + end + end + + private + + def run_shell_command(global, options, args) + require 'leap_cli/ssh' + cmd = global[:force] ? options[:cmd] : LeapCli::SSH::Options.sanitize_command(options[:cmd]) + nodes = manager.filter!(args) + if options[:stream] + stream_command(nodes, cmd, options) + else + capture_command(nodes, cmd, options) + end + end + + def capture_command(nodes, cmd, options) + SSH.remote_command(nodes, options) do |ssh, host| + output = ssh.capture(cmd, :log_output => false) + if output + logger = LeapCli.new_logger + logger.log(:ran, "`" + cmd + "`", host: host.hostname, color: :green) do + logger.log(output, wrap: true) + end + end + end + end + + def stream_command(nodes, cmd, options) + SSH.remote_command(nodes, options) do |ssh, host| + ssh.stream(cmd, :log_cmd => true, :log_finish => true, :fail_msg => 'oops') + end + end + +end; end + + diff --git a/lib/leap_cli/commands/ssh.rb b/lib/leap_cli/commands/ssh.rb index 3887618e..695812b8 100644 --- a/lib/leap_cli/commands/ssh.rb +++ b/lib/leap_cli/commands/ssh.rb @@ -69,20 +69,6 @@ module LeapCli; module Commands protected - # - # allow for ssh overrides of all commands that use ssh_connect - # - def connect_options(options) - connect_options = {:ssh_options=>{}} - if options[:port] - connect_options[:ssh_options][:port] = options[:port] - end - if options[:ip] - connect_options[:ssh_options][:host_name] = options[:ip] - end - return connect_options - end - def ssh_config_help_message puts "" puts "Are 'too many authentication failures' getting you down?" diff --git a/lib/leap_cli/commands/test.rb b/lib/leap_cli/commands/test.rb index f506d849..70eb00fd 100644 --- a/lib/leap_cli/commands/test.rb +++ b/lib/leap_cli/commands/test.rb @@ -7,24 +7,7 @@ module LeapCli; module Commands test.command :run do |run| run.switch 'continue', :desc => 'Continue over errors and failures (default is --no-continue).', :negatable => true run.action do |global_options,options,args| - test_order = File.join(Path.platform, 'tests/order.rb') - if File.exist?(test_order) - require test_order - end - manager.filter!(args).names_in_test_dependency_order.each do |node_name| - node = manager.nodes[node_name] - begin - ssh_connect(node) do |ssh| - ssh.run(test_cmd(options)) - end - rescue Capistrano::CommandError - if options[:continue] - exit_status(1) - else - bail! - end - end - end + do_test_run(global_options, options, args) end end @@ -40,6 +23,28 @@ module LeapCli; module Commands private + def do_test_run(global_options, options, args) + require 'leap_cli/ssh' + test_order = File.join(Path.platform, 'tests/order.rb') + if File.exist?(test_order) + require test_order + end + manager.filter!(args).names_in_test_dependency_order.each do |node_name| + node = manager.nodes[node_name] + begin + SSH::remote_command(node, options) do |ssh, host| + ssh.stream(test_cmd(options), :raise_error => true, :log_wrap => true) + end + rescue LeapCli::SSH::ExecuteError + if options[:continue] + exit_status(1) + else + bail! + end + end + end + end + def test_cmd(options) if options[:continue] "#{Leap::Platform.leap_dir}/bin/run_tests --continue" diff --git a/lib/leap_cli/commands/user.rb b/lib/leap_cli/commands/user.rb index b842e854..68f42059 100644 --- a/lib/leap_cli/commands/user.rb +++ b/lib/leap_cli/commands/user.rb @@ -22,58 +22,67 @@ module LeapCli c.flag 'pgp-pub-key', :desc => 'OpenPGP public key file for this new user' c.action do |global_options,options,args| - username = args.first - if !username.any? - if options[:self] - username ||= `whoami`.strip - else - help! "Either USERNAME argument or --self flag is required." - end - end - if Leap::Platform.reserved_usernames.include? username - bail! %(The username "#{username}" is reserved. Sorry, pick another.) - end + do_add_user(global_options, optinos, args) + end + end - ssh_pub_key = nil - pgp_pub_key = nil + private - if options['ssh-pub-key'] - ssh_pub_key = read_file!(options['ssh-pub-key']) - end - if options['pgp-pub-key'] - pgp_pub_key = read_file!(options['pgp-pub-key']) - end + def do_add_user(global, options, args) + require 'leap_cli/ssh' + username = args.first + if !username.any? if options[:self] - ssh_pub_key ||= pick_ssh_key.to_s - pgp_pub_key ||= pick_pgp_key + username ||= `whoami`.strip + else + help! "Either USERNAME argument or --self flag is required." end + end + if Leap::Platform.reserved_usernames.include? username + bail! %(The username "#{username}" is reserved. Sorry, pick another.) + end - assert!(ssh_pub_key, 'Sorry, could not find SSH public key.') + ssh_pub_key = nil + pgp_pub_key = nil - if ssh_pub_key - write_file!([:user_ssh, username], ssh_pub_key) - end - if pgp_pub_key - write_file!([:user_pgp, username], pgp_pub_key) - end + if options['ssh-pub-key'] + ssh_pub_key = read_file!(options['ssh-pub-key']) + end + if options['pgp-pub-key'] + pgp_pub_key = read_file!(options['pgp-pub-key']) + end - update_authorized_keys + if options[:self] + ssh_pub_key ||= pick_ssh_key.to_s + pgp_pub_key ||= pick_pgp_key end + + assert!(ssh_pub_key, 'Sorry, could not find SSH public key.') + + if ssh_pub_key + write_file!([:user_ssh, username], ssh_pub_key) + end + if pgp_pub_key + write_file!([:user_pgp, username], pgp_pub_key) + end + + update_authorized_keys end # - # let the the user choose among the ssh public keys that we encounter, or just pick the key if there is only one. + # let the the user choose among the ssh public keys that we encounter, or + # just pick the key if there is only one. # def pick_ssh_key ssh_keys = [] Dir.glob("#{ENV['HOME']}/.ssh/*.pub").each do |keyfile| - ssh_keys << SshKey.load(keyfile) + ssh_keys << SSH::Key.load(keyfile) end if `which ssh-add`.strip.any? `ssh-add -L 2> /dev/null`.split("\n").compact.each do |line| - key = SshKey.load(line) + key = SSH::Key.load(line) if key key.comment = 'ssh-agent' ssh_keys << key unless ssh_keys.include?(key) diff --git a/lib/leap_cli/commands/util.rb b/lib/leap_cli/commands/util.rb index c1da570e..e2dc03a0 100644 --- a/lib/leap_cli/commands/util.rb +++ b/lib/leap_cli/commands/util.rb @@ -2,7 +2,6 @@ module LeapCli; module Commands extend self extend LeapCli::Util - extend LeapCli::Util::RemoteCommand def path(name) Path.named_path(name) diff --git a/lib/leap_cli/ssh.rb b/lib/leap_cli/ssh.rb new file mode 100644 index 00000000..8b604d1d --- /dev/null +++ b/lib/leap_cli/ssh.rb @@ -0,0 +1,7 @@ +require 'sshkit' +require_relative 'ssh/options' +require_relative 'ssh/backend' +require_relative 'ssh/formatter' +require_relative 'ssh/scripts' +require_relative 'ssh/remote_command' +require_relative 'ssh/key' diff --git a/lib/leap_cli/ssh/backend.rb b/lib/leap_cli/ssh/backend.rb new file mode 100644 index 00000000..35277039 --- /dev/null +++ b/lib/leap_cli/ssh/backend.rb @@ -0,0 +1,154 @@ +# +# A custome SSHKit backend, derived from the default netssh backend. +# Our custom backend modifies the logging behavior and gracefully captures +# common exceptions. +# + +require 'sshkit' +require 'leap_cli/ssh/formatter' +require 'leap_cli/ssh/scripts' + +module SSHKit + class Command + # + # override exit_status in order to be less verbose + # + def exit_status=(new_exit_status) + @finished_at = Time.now + @exit_status = new_exit_status + if options[:raise_on_non_zero_exit] && exit_status > 0 + message = "" + message += "exit status: " + exit_status.to_s + "\n" + message += "stdout: " + (full_stdout.strip.empty? ? "Nothing written" : full_stdout.strip) + "\n" + message += "stderr: " + (full_stderr.strip.empty? ? 'Nothing written' : full_stderr.strip) + "\n" + raise Failed, message + end + end + end +end + +module LeapCli + module SSH + class Backend < SSHKit::Backend::Netssh + + # since the @pool is a class instance variable, we need to copy + # the code from the superclass that initializes it. boo + @pool = SSHKit::Backend::ConnectionPool.new + + # modify to pass itself to the block, instead of relying on instance_exec. + def run + Thread.current["sshkit_backend"] = self + # was: instance_exec(@host, &@block) + @block.call(self, @host) + ensure + Thread.current["sshkit_backend"] = nil + end + + # + # like default capture, but gracefully logs failures for us + # last argument can be an options hash. + # + # available options: + # + # :fail_msg - [nil] if set, log this instead of the default + # fail message. + # + # :raise_error - [nil] if true, then reraise failed command exception. + # + # :log_cmd - [false] if true, log what the command is that gets run. + # + # :log_output - [true] if true, log each output from the command as + # it is received. + # + # :log_finish - [false] if true, log the exit status and time + # to completion + # + # :log_wrap - [nil] passed to log method as :wrap option. + # + def capture(*args) + extract_options(args) + initialize_logger(:log_output => false) + rescue_ssh_errors(*args) do + return super(*args) + end + end + + # + # like default execute, but log the results as they come in. + # + # see capture() for available options + # + def stream(*args) + extract_options(args) + initialize_logger + rescue_ssh_errors(*args) do + execute(*args) + end + end + + def log(*args, &block) + @logger ||= LeapCli.new_logger + @logger.log(*args, &block) + end + + # some prewritten servers-side scripts + def scripts + @scripts ||= LeapCli::SSH::Scripts.new(self, @host) + end + + private + + # + # creates a new logger instance for this specific ssh command. + # by doing this, each ssh session has its own logger and its own + # indentation. + # + # potentially modifies 'args' array argument. + # + def initialize_logger(default_options={}) + @logger ||= LeapCli.new_logger + @output = LeapCli::SSH::Formatter.new(@logger, @host, default_options.merge(@options)) + end + + def extract_options(args) + if args.last.is_a? Hash + @options = args.pop + else + @options = {} + end + end + + # + # capture common exceptions + # + def rescue_ssh_errors(*args, &block) + yield + rescue StandardError => exc + if exc.is_a?(SSHKit::Command::Failed) || exc.is_a?(SSHKit::Runner::ExecuteError) + if @options[:raise_error] + raise LeapCli::SSH::ExecuteError, exc.to_s + elsif @options[:fail_msg] + @logger.log(@options[:fail_msg], host: @host.hostname, :color => :red) + else + @logger.log(:failed, args.join(' '), host: @host.hostname) do + @logger.log(exc.to_s.strip, wrap: true) + end + end + elsif exc.is_a?(Timeout::Error) + @logger.log(:failed, args.join(' '), host: @host.hostname) do + @logger.log("Connection timed out") + end + else + @logger.log(:error, "unknown exception: " + exc.to_s) + end + return nil + end + + def output + @output ||= LeapCli::SSH::Formatter.new(@logger, @host) + end + + end + end +end + diff --git a/lib/leap_cli/ssh/formatter.rb b/lib/leap_cli/ssh/formatter.rb new file mode 100644 index 00000000..84a8e797 --- /dev/null +++ b/lib/leap_cli/ssh/formatter.rb @@ -0,0 +1,75 @@ +# +# A custom SSHKit formatter that uses LeapLogger. +# + +require 'sshkit' + +module LeapCli + module SSH + + class Formatter < SSHKit::Formatter::Abstract + + DEFAULT_OPTIONS = { + :log_cmd => false, # log what the command is that gets run. + :log_output => true, # log each output from the command as it is received. + :log_finish => false # log the exit status and time to completion. + } + + def initialize(logger, host, options={}) + @logger = logger + @host = host + @options = DEFAULT_OPTIONS.merge(options) + end + + def write(obj) + @logger.log(obj.to_s, :host => @host) + end + + def log_command_start(command) + if @options[:log_cmd] + @logger.log(:running, "`" + command.to_s + "`", :host => @host) + end + end + + def log_command_data(command, stream_type, stream_data) + if @options[:log_output] + color = \ + case stream_type + when :stdout then :cyan + when :stderr then :red + else raise "Unrecognised stream_type #{stream_type}, expected :stdout or :stderr" + end + @logger.log(stream_data.to_s.chomp, + :color => color, :host => @host, :wrap => options[:log_wrap]) + end + end + + def log_command_exit(command) + if @options[:log_finish] + runtime = sprintf('%5.3fs', command.runtime) + if command.failure? + message = "in #{runtime} with status #{command.exit_status}." + @logger.log(:failed, message, :host => @host) + else + message = "in #{runtime}." + @logger.log(:completed, message, :host => @host) + end + end + end + end + + end +end + + # + # A custom InteractionHandler that will output the results as they come in. + # + #class LoggingInteractionHandler + # def initialize(hostname, logger=nil) + # @hostname = hostname + # @logger = logger || LeapCli.new_logger + # end + # def on_data(command, stream_name, data, channel) + # @logger.log(data, host: @hostname, wrap: true) + # end + #end diff --git a/lib/leap_cli/ssh/key.rb b/lib/leap_cli/ssh/key.rb new file mode 100644 index 00000000..ad1ecf15 --- /dev/null +++ b/lib/leap_cli/ssh/key.rb @@ -0,0 +1,199 @@ +# +# A wrapper around OpenSSL::PKey::RSA instances to provide a better api for +# dealing with SSH keys. +# +# NOTE: cipher 'ssh-ed25519' not supported yet because we are waiting +# for support in Net::SSH +# + +require 'net/ssh' +require 'forwardable' + +module LeapCli + module SSH + class Key + extend Forwardable + + attr_accessor :filename + attr_accessor :comment + + # supported ssh key types, in order of preference + SUPPORTED_TYPES = ['ssh-rsa', 'ecdsa-sha2-nistp256'] + SUPPORTED_TYPES_RE = /(#{SUPPORTED_TYPES.join('|')})/ + + ## + ## CLASS METHODS + ## + + def self.load(arg1, arg2=nil) + key = nil + if arg1.is_a? OpenSSL::PKey::RSA + key = Key.new arg1 + elsif arg1.is_a? String + if arg1 =~ /^ssh-/ + type, data = arg1.split(' ') + key = Key.new load_from_data(data, type) + elsif File.exist? arg1 + key = Key.new load_from_file(arg1) + key.filename = arg1 + else + key = Key.new load_from_data(arg1, arg2) + end + end + return key + rescue StandardError + end + + def self.load_from_file(filename) + public_key = nil + private_key = nil + begin + public_key = Net::SSH::KeyFactory.load_public_key(filename) + rescue NotImplementedError, Net::SSH::Exception, OpenSSL::PKey::PKeyError + begin + private_key = Net::SSH::KeyFactory.load_private_key(filename) + rescue NotImplementedError, Net::SSH::Exception, OpenSSL::PKey::PKeyError + end + end + public_key || private_key + end + + def self.load_from_data(data, type='ssh-rsa') + public_key = nil + private_key = nil + begin + public_key = Net::SSH::KeyFactory.load_data_public_key("#{type} #{data}") + rescue NotImplementedError, Net::SSH::Exception, OpenSSL::PKey::PKeyError + begin + private_key = Net::SSH::KeyFactory.load_data_private_key("#{type} #{data}") + rescue NotImplementedError, Net::SSH::Exception, OpenSSL::PKey::PKeyError + end + end + public_key || private_key + end + + # + # Picks one key out of an array of keys that we think is the "best", + # based on the order of preference in SUPPORTED_TYPES + # + # Currently, this does not take bitsize into account. + # + def self.pick_best_key(keys) + keys.select {|k| + SUPPORTED_TYPES.include?(k.type) + }.sort {|a,b| + SUPPORTED_TYPES.index(a.type) <=> SUPPORTED_TYPES.index(b.type) + }.first + end + + # + # takes a string with one or more ssh keys, one key per line, + # and returns an array of Key objects. + # + # the lines should be in one of these formats: + # + # 1. + # 2. + # + def self.parse_keys(string) + keys = [] + lines = string.split("\n").grep(/^[^#]/) + lines.each do |line| + if line =~ / #{Key::SUPPORTED_TYPES_RE} / + # + keys << line.split(' ')[1..2] + elsif line =~ /^#{Key::SUPPORTED_TYPES_RE} / + # + keys << line.split(' ') + end + end + return keys.map{|k| Key.load(k[1], k[0])} + end + + # + # takes a string with one or more ssh keys, one key per line, + # and returns a string that specified the ssh key algorithms + # that are supported by the keys, in order of preference. + # + # eg: ecdsa-sha2-nistp256,ssh-rsa,ssh-ed25519 + # + def self.supported_host_key_algorithms(string) + if string + self.parse_keys(string).map {|key| + key.type + }.join(',') + else + "" + end + end + + ## + ## INSTANCE METHODS + ## + + public + + def initialize(rsa_key) + @key = rsa_key + end + + def_delegator :@key, :fingerprint, :fingerprint + def_delegator :@key, :public?, :public? + def_delegator :@key, :private?, :private? + def_delegator :@key, :ssh_type, :type + def_delegator :@key, :public_encrypt, :public_encrypt + def_delegator :@key, :public_decrypt, :public_decrypt + def_delegator :@key, :private_encrypt, :private_encrypt + def_delegator :@key, :private_decrypt, :private_decrypt + def_delegator :@key, :params, :params + def_delegator :@key, :to_text, :to_text + + def public_key + Key.new(@key.public_key) + end + + def private_key + Key.new(@key.private_key) + end + + # + # not sure if this will always work, but is seems to for now. + # + def bits + Net::SSH::Buffer.from(:key, @key).to_s.split("\001\000").last.size * 8 + end + + def summary + if self.filename + "%s %s %s (%s)" % [self.type, self.bits, self.fingerprint, File.basename(self.filename)] + else + "%s %s %s" % [self.type, self.bits, self.fingerprint] + end + end + + def to_s + self.type + " " + self.key + end + + def key + [Net::SSH::Buffer.from(:key, @key).to_s].pack("m*").gsub(/\s/, "") + end + + def ==(other_key) + return false if other_key.nil? + return false if self.class != other_key.class + return self.to_text == other_key.to_text + end + + def in_known_hosts?(*identifiers) + identifiers.each do |identifier| + Net::SSH::KnownHosts.search_for(identifier).each do |key| + return true if self == key + end + end + return false + end + + end + end +end diff --git a/lib/leap_cli/ssh/options.rb b/lib/leap_cli/ssh/options.rb new file mode 100644 index 00000000..0bbaa36f --- /dev/null +++ b/lib/leap_cli/ssh/options.rb @@ -0,0 +1,93 @@ +# +# Options for passing to the ruby gem ssh-net +# + +module LeapCli + module SSH + module Options + + def self.global_options + { + #:keys_only => true, + :global_known_hosts_file => Path.named_path(:known_hosts), + :user_known_hosts_file => '/dev/null', + :paranoid => true, + :verbose => net_ssh_log_level, + :auth_methods => ["publickey"], + :timeout => 5 + } + end + + def self.node_options(node, ssh_options_override=nil) + { + # :host_key_alias => node.name, << incompatible with ports in known_hosts + :host_name => node.ip_address, + :port => node.ssh.port + }.merge( + contingent_ssh_options_for_node(node) + ).merge( + ssh_options_override||{} + ) + end + + def self.options_from_args(args) + ssh_options = {} + if args[:port] + ssh_options[:port] = args[:port] + end + if args[:ip] + ssh_options[:host_name] = args[:ip] + end + if args[:auth_methods] + ssh_options[:auth_methods] = args[:auth_methods] + end + return ssh_options + end + + def self.sanitize_command(cmd) + if cmd =~ /(^|\/| )rm / || cmd =~ /(^|\/| )unlink / + LeapCli.log :warning, "You probably don't want to do that. Run with --force if you are really sure." + exit(1) + else + cmd + end + end + + private + + def self.contingent_ssh_options_for_node(node) + opts = {} + if node.vagrant? + opts[:keys] = [vagrant_ssh_key_file] + opts[:keys_only] = true # only use the keys specified above, and + # ignore whatever keys the ssh-agent is aware of. + opts[:paranoid] = false # we skip host checking for vagrant nodes, + # because fingerprint is different for everyone. + if LeapCli.logger.log_level <= 1 + opts[:verbose] = :error # suppress all the warnings about adding + # host keys to known_hosts, since it is + # not actually doing that. + end + end + if !node.supported_ssh_host_key_algorithms.empty? + opts[:host_key] = node.supported_ssh_host_key_algorithms + end + return opts + end + + def self.net_ssh_log_level + if DEBUG + case LeapCli.logger.log_level + when 1 then 3 + when 2 then 2 + when 3 then 1 + else 0 + end + else + nil + end + end + + end + end +end \ No newline at end of file diff --git a/lib/leap_cli/ssh/remote_command.rb b/lib/leap_cli/ssh/remote_command.rb new file mode 100644 index 00000000..fe9a344a --- /dev/null +++ b/lib/leap_cli/ssh/remote_command.rb @@ -0,0 +1,107 @@ +# +# Provides SSH.remote_command for running commands in parallel or in sequence +# on remote servers. +# +# The gem sshkit is used for this. +# + +require 'sshkit' +require 'leap_cli/ssh/options' +require 'leap_cli/ssh/backend' + +SSHKit.config.backend = LeapCli::SSH::Backend +LeapCli::SSH::Backend.config.ssh_options = LeapCli::SSH::Options.global_options + +# +# define remote_command +# +module LeapCli + module SSH + + class ExecuteError < StandardError + end + + # override default runner mode + class CustomCoordinator < SSHKit::Coordinator + private + def default_options + { in: :groups, limit: 10, wait: 0 } + end + end + + # + # Available options: + # + # :port -- ssh port + # :ip -- ssh ip + # :auth_methods -- e.g. ["pubkey", "password"] + # + def self.remote_command(nodes, options={}, &block) + CustomCoordinator.new( + host_list( + nodes, + SSH::Options.options_from_args(options) + ) + ).each do |ssh, host| + LeapCli.log 2, "ssh options for #{host.hostname}: #{host.ssh_options.inspect}" + yield ssh, host + end + end + + # + # For example: + # + # SSH.remote_sync(nodes) do |sync, host| + # sync.source = '/from' + # sync.dest = '/to' + # sync.flags = '' + # sync.includes = [] + # sync.excludes = [] + # sync.exec + # end + # + def self.remote_sync(nodes, options={}, &block) + require 'rsync_command' + hosts = host_list( + nodes, + SSH::Options.options_from_args(options) + ) + rsync = RsyncCommand.new(:logger => LeapCli::logger) + rsync.asynchronously(hosts) do |sync, host| + sync.logger = LeapCli.new_logger + sync.user = host.user || fetch(:user, ENV['USER']) + sync.host = host.hostname + sync.ssh = SSH::Options.global_options.merge(host.ssh_options) + sync.chdir = Path.provider + yield(sync, host) + end + if rsync.failed? + LeapCli::Util.bail! do + LeapCli.log :failed, "to rsync to #{rsync.failures.map{|f|f[:dest][:host]}.join(' ')}" + end + end + end + + private + + def self.host_list(nodes, ssh_options_override={}) + if nodes.is_a?(Config::ObjectList) + list = nodes.values + elsif nodes.is_a?(Config::Node) + list = [nodes] + else + raise ArgumentError, "I don't understand the type of argument `nodes`" + end + list.collect do |node| + SSHKit::Host.new( + :hostname => node.name, + :user => 'root', + :ssh_options => SSH::Options.node_options(node, ssh_options_override) + ) + end + end + + end +end + + diff --git a/lib/leap_cli/ssh/scripts.rb b/lib/leap_cli/ssh/scripts.rb new file mode 100644 index 00000000..3d8b6570 --- /dev/null +++ b/lib/leap_cli/ssh/scripts.rb @@ -0,0 +1,136 @@ +# +# Common commands that we would like to run on remote servers. +# +# These scripts are available via: +# +# SSH.remote_command(nodes) do |ssh, host| +# ssh.script.custom_script_name +# end +# + +module LeapCli + module SSH + class Scripts + + REQUIRED_PACKAGES = "puppet rsync lsb-release locales" + + attr_reader :ssh, :host + def initialize(backend, host) + @ssh = backend + @host = host + end + + # + # creates directories that are owned by root and 700 permissions + # + def mkdirs(*dirs) + raise ArgumentError.new('illegal dir name') if dirs.grep(/[\' ]/).any? + ssh.stream dirs.collect{|dir| "mkdir -m 700 -p #{dir}; "}.join + end + + # + # echos "ok" if the node has been initialized and the required packages are installed, bails out otherwise. + # + def assert_initialized + begin + test_initialized_file = "test -f #{Leap::Platform.init_path}" + check_required_packages = "! dpkg-query -W --showformat='${Status}\n' #{REQUIRED_PACKAGES} 2>&1 | grep -q -E '(deinstall|no packages)'" + ssh.stream "#{test_initialized_file} && #{check_required_packages} && echo ok", :raise_error => true + rescue SSH::ExecuteError + ssh.log :error, "running deploy: node not initialized. Run `leap node init #{host}`.", :host => host + raise # will skip further action on this node + end + end + + # + # bails out the deploy if the file /etc/leap/no-deploy exists. + # + def check_for_no_deploy + begin + ssh.stream "test ! -f /etc/leap/no-deploy", :raise_error => true, :log_output => false + rescue SSH::ExecuteError + ssh.log :warning, "can't continue because file /etc/leap/no-deploy exists", :host => host + raise # will skip further action on this node + end + end + + # + # dumps debugging information + # + def debug + output = ssh.capture "#{Leap::Platform.leap_dir}/bin/debug.sh" + ssh.log(output, :wrap => true, :host => host.hostname, :color => :cyan) + end + + # + # dumps the recent deploy history to the console + # + def history(lines) + cmd = "(test -s /var/log/leap/deploy-summary.log && tail -n #{lines} /var/log/leap/deploy-summary.log) || (test -s /var/log/leap/deploy-summary.log.1 && tail -n #{lines} /var/log/leap/deploy-summary.log.1) || (echo 'no history')" + history = ssh.capture(cmd, :log_output => false) + if history + ssh.log host.hostname, :color => :cyan, :style => :bold do + ssh.log history, :wrap => true + end + end + end + + # + # apply puppet! weeeeeee + # + def puppet_apply(options) + cmd = "#{Leap::Platform.leap_dir}/bin/puppet_command set_hostname apply #{flagize(options)}" + ssh.stream cmd, :log_finish => true + end + + def install_authorized_keys + ssh.log :updating, "authorized_keys" do + mkdirs '/root/.ssh' + ssh.upload! LeapCli::Path.named_path(:authorized_keys), '/root/.ssh/authorized_keys', :mode => '600' + end + end + + # + # for vagrant nodes, we install insecure vagrant key to authorized_keys2, since deploy + # will overwrite authorized_keys. + # + # why force the insecure vagrant key? + # if we don't do this, then first time initialization might fail if the user has many keys + # (ssh will bomb out before it gets to the vagrant key). + # and it really doesn't make sense to ask users to pin the insecure vagrant key in their + # .ssh/config files. + # + def install_insecure_vagrant_key + ssh.log :installing, "insecure vagrant key" do + mkdirs '/root/.ssh' + ssh.upload! LeapCli::Path.vagrant_ssh_pub_key_file, '/root/.ssh/authorized_keys2', :mode => '600' + end + end + + def install_prerequisites + bin_dir = File.join(Leap::Platform.leap_dir, 'bin') + node_init_path = File.join(bin_dir, 'node_init') + ssh.log :running, "node_init script" do + mkdirs bin_dir + ssh.upload! LeapCli::Path.node_init_script, node_init_path, :mode => '500' + ssh.stream node_init_path + end + end + + private + + def flagize(hsh) + hsh.inject([]) {|str, item| + if item[1] === false + str + elsif item[1] === true + str << "--" + item[0].to_s + else + str << "--" + item[0].to_s + " " + item[1].inspect + end + }.join(' ') + end + + end + end +end \ No newline at end of file -- cgit v1.2.3