diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/leap_cli/commands/deploy.rb | 201 | ||||
| -rw-r--r-- | lib/leap_cli/commands/facts.rb | 13 | ||||
| -rw-r--r-- | lib/leap_cli/commands/info.rb | 15 | ||||
| -rw-r--r-- | lib/leap_cli/commands/node_init.rb | 87 | ||||
| -rw-r--r-- | lib/leap_cli/commands/run.rb | 47 | ||||
| -rw-r--r-- | lib/leap_cli/commands/ssh.rb | 14 | ||||
| -rw-r--r-- | lib/leap_cli/commands/test.rb | 41 | ||||
| -rw-r--r-- | lib/leap_cli/commands/user.rb | 73 | ||||
| -rw-r--r-- | lib/leap_cli/commands/util.rb | 1 | ||||
| -rw-r--r-- | lib/leap_cli/ssh.rb | 7 | ||||
| -rw-r--r-- | lib/leap_cli/ssh/backend.rb | 154 | ||||
| -rw-r--r-- | lib/leap_cli/ssh/formatter.rb | 75 | ||||
| -rw-r--r-- | lib/leap_cli/ssh/key.rb | 199 | ||||
| -rw-r--r-- | lib/leap_cli/ssh/options.rb | 93 | ||||
| -rw-r--r-- | lib/leap_cli/ssh/remote_command.rb | 107 | ||||
| -rw-r--r-- | lib/leap_cli/ssh/scripts.rb | 136 | 
16 files changed, 1054 insertions, 209 deletions
| 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. <hostname> <key-type> <key> +      # 2. <key-type> <key> +      # +      def self.parse_keys(string) +        keys = [] +        lines = string.split("\n").grep(/^[^#]/) +        lines.each do |line| +          if line =~ / #{Key::SUPPORTED_TYPES_RE} / +            # <hostname> <key-type> <key> +            keys << line.split(' ')[1..2] +          elsif line =~ /^#{Key::SUPPORTED_TYPES_RE} / +            # <key-type> <key> +            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 | 
