diff options
Diffstat (limited to 'lib/chef/knife/ssh.rb')
-rw-r--r-- | lib/chef/knife/ssh.rb | 444 |
1 files changed, 444 insertions, 0 deletions
diff --git a/lib/chef/knife/ssh.rb b/lib/chef/knife/ssh.rb new file mode 100644 index 0000000000..a1b37723a6 --- /dev/null +++ b/lib/chef/knife/ssh.rb @@ -0,0 +1,444 @@ +# +# Author:: Adam Jacob (<adam@opscode.com>) +# Copyright:: Copyright (c) 2009 Opscode, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require 'chef/knife' + +class Chef + class Knife + class Ssh < Knife + + deps do + require 'net/ssh' + require 'net/ssh/multi' + require 'readline' + require 'chef/exceptions' + require 'chef/search/query' + require 'chef/mixin/shell_out' + require 'mixlib/shellout' + end + + include Chef::Mixin::ShellOut + + attr_writer :password + + banner "knife ssh QUERY COMMAND (options)" + + option :concurrency, + :short => "-C NUM", + :long => "--concurrency NUM", + :description => "The number of concurrent connections", + :default => nil, + :proc => lambda { |o| o.to_i } + + option :attribute, + :short => "-a ATTR", + :long => "--attribute ATTR", + :description => "The attribute to use for opening the connection - default depends on the context", + :proc => Proc.new { |key| Chef::Config[:knife][:ssh_attribute] = key.strip } + + option :manual, + :short => "-m", + :long => "--manual-list", + :boolean => true, + :description => "QUERY is a space separated list of servers", + :default => false + + option :ssh_user, + :short => "-x USERNAME", + :long => "--ssh-user USERNAME", + :description => "The ssh username" + + option :ssh_password, + :short => "-P PASSWORD", + :long => "--ssh-password PASSWORD", + :description => "The ssh password" + + option :ssh_port, + :short => "-p PORT", + :long => "--ssh-port PORT", + :description => "The ssh port", + :default => "22", + :proc => Proc.new { |key| Chef::Config[:knife][:ssh_port] = key } + + option :ssh_gateway, + :short => "-G GATEWAY", + :long => "--ssh-gateway GATEWAY", + :description => "The ssh gateway", + :proc => Proc.new { |key| Chef::Config[:knife][:ssh_gateway] = key } + + option :identity_file, + :short => "-i IDENTITY_FILE", + :long => "--identity-file IDENTITY_FILE", + :description => "The SSH identity file used for authentication" + + option :host_key_verify, + :long => "--[no-]host-key-verify", + :description => "Verify host key, enabled by default.", + :boolean => true, + :default => true + + def session + config[:on_error] ||= :skip + ssh_error_handler = Proc.new do |server| + if config[:manual] + node_name = server.host + else + @action_nodes.each do |n| + node_name = n if format_for_display(n)[config[:attribute]] == server.host + end + end + case config[:on_error] + when :skip + ui.warn "Failed to connect to #{node_name} -- #{$!.class.name}: #{$!.message}" + $!.backtrace.each { |l| Chef::Log.debug(l) } + when :raise + #Net::SSH::Multi magic to force exception to be re-raised. + throw :go, :raise + end + end + + @session ||= Net::SSH::Multi.start(:concurrent_connections => config[:concurrency], :on_error => ssh_error_handler) + end + + def configure_session + list = case config[:manual] + when true + @name_args[0].split(" ") + when false + r = Array.new + q = Chef::Search::Query.new + @action_nodes = q.search(:node, @name_args[0])[0] + @action_nodes.each do |item| + # we should skip the loop to next iteration if the item returned by the search is nil + next if item.nil? + # if a command line attribute was not passed, and we have a cloud public_hostname, use that. + # see #configure_attribute for the source of config[:attribute] and config[:override_attribute] + if !config[:override_attribute] && item[:cloud] and item[:cloud][:public_hostname] + i = item[:cloud][:public_hostname] + elsif config[:override_attribute] + i = extract_nested_value(item, config[:override_attribute]) + else + i = extract_nested_value(item, config[:attribute]) + end + # next if we couldn't find the specified attribute in the returned node object + next if i.nil? + r.push(i) + end + r + end + if list.length == 0 + if @action_nodes.length == 0 + ui.fatal("No nodes returned from search!") + else + ui.fatal("#{@action_nodes.length} #{@action_nodes.length > 1 ? "nodes":"node"} found, " + + "but do not have the required attribute to stablish the connection. " + + "Try setting another attribute to open the connection using --attribute.") + end + exit 10 + end + session_from_list(list) + end + + def session_from_list(list) + config[:ssh_gateway] ||= Chef::Config[:knife][:ssh_gateway] + if config[:ssh_gateway] + gw_host, gw_user = config[:ssh_gateway].split('@').reverse + gw_host, gw_port = gw_host.split(':') + gw_opts = gw_port ? { :port => gw_port } : {} + + session.via(gw_host, gw_user || config[:ssh_user], gw_opts) + end + + list.each do |item| + Chef::Log.debug("Adding #{item}") + + hostspec = config[:ssh_user] ? "#{config[:ssh_user]}@#{item}" : item + session_opts = {} + session_opts[:keys] = File.expand_path(config[:identity_file]) if config[:identity_file] + session_opts[:keys_only] = true if config[:identity_file] + session_opts[:password] = config[:ssh_password] if config[:ssh_password] + session_opts[:port] = Chef::Config[:knife][:ssh_port] || config[:ssh_port] + session_opts[:logger] = Chef::Log.logger if Chef::Log.level == :debug + + if !config[:host_key_verify] + session_opts[:paranoid] = false + session_opts[:user_known_hosts_file] = "/dev/null" + end + + session.use(hostspec, session_opts) + + @longest = item.length if item.length > @longest + end + + session + end + + def fixup_sudo(command) + command.sub(/^sudo/, 'sudo -p \'knife sudo password: \'') + end + + def print_data(host, data) + if data =~ /\n/ + data.split(/\n/).each { |d| print_data(host, d) } + else + padding = @longest - host.length + str = ui.color(host, :cyan) + (" " * (padding + 1)) + data + ui.msg(str) + end + end + + def ssh_command(command, subsession=nil) + exit_status = 0 + subsession ||= session + command = fixup_sudo(command) + command.force_encoding('binary') if command.respond_to?(:force_encoding) + subsession.open_channel do |ch| + ch.request_pty + ch.exec command do |ch, success| + raise ArgumentError, "Cannot execute #{command}" unless success + ch.on_data do |ichannel, data| + print_data(ichannel[:host], data) + if data =~ /^knife sudo password: / + ichannel.send_data("#{get_password}\n") + end + end + ch.on_request "exit-status" do |ichannel, data| + exit_status = data.read_long + end + end + end + session.loop + exit_status + end + + def get_password + @password ||= ui.ask("Enter your password: ") { |q| q.echo = false } + end + + # Present the prompt and read a single line from the console. It also + # detects ^D and returns "exit" in that case. Adds the input to the + # history, unless the input is empty. Loops repeatedly until a non-empty + # line is input. + def read_line + loop do + command = reader.readline("#{ui.color('knife-ssh>', :bold)} ", true) + + if command.nil? + command = "exit" + puts(command) + else + command.strip! + end + + unless command.empty? + return command + end + end + end + + def reader + Readline + end + + def interactive + puts "Connected to #{ui.list(session.servers_for.collect { |s| ui.color(s.host, :cyan) }, :inline, " and ")}" + puts + puts "To run a command on a list of servers, do:" + puts " on SERVER1 SERVER2 SERVER3; COMMAND" + puts " Example: on latte foamy; echo foobar" + puts + puts "To exit interactive mode, use 'quit!'" + puts + while 1 + command = read_line + case command + when 'quit!' + puts 'Bye!' + break + when /^on (.+?); (.+)$/ + raw_list = $1.split(" ") + server_list = Array.new + session.servers.each do |session_server| + server_list << session_server if raw_list.include?(session_server.host) + end + command = $2 + ssh_command(command, session.on(*server_list)) + else + ssh_command(command) + end + end + end + + def screen + tf = Tempfile.new("knife-ssh-screen") + if File.exist? "#{ENV["HOME"]}/.screenrc" + tf.puts("source #{ENV["HOME"]}/.screenrc") + end + tf.puts("caption always '%-Lw%{= BW}%50>%n%f* %t%{-}%+Lw%<'") + tf.puts("hardstatus alwayslastline 'knife ssh #{@name_args[0]}'") + window = 0 + session.servers_for.each do |server| + tf.print("screen -t \"#{server.host}\" #{window} ssh ") + tf.print("-i #{config[:identity_file]} ") if config[:identity_file] + server.user ? tf.puts("#{server.user}@#{server.host}") : tf.puts(server.host) + window += 1 + end + tf.close + exec("screen -c #{tf.path}") + end + + def tmux + ssh_dest = lambda do |server| + identity = "-i #{config[:identity_file]} " if config[:identity_file] + prefix = server.user ? "#{server.user}@" : "" + "'ssh #{identity}#{prefix}#{server.host}'" + end + + new_window_cmds = lambda do + if session.servers_for.size > 1 + [""] + session.servers_for[1..-1].map do |server| + "new-window -a -n '#{server.host}' #{ssh_dest.call(server)}" + end + else + [] + end.join(" \\; ") + end + + tmux_name = "'knife ssh #{@name_args[0].gsub(/:/,'=')}'" + begin + server = session.servers_for.first + cmd = ["tmux new-session -d -s #{tmux_name}", + "-n '#{server.host}'", ssh_dest.call(server), + new_window_cmds.call].join(" ") + shell_out!(cmd) + exec("tmux attach-session -t #{tmux_name}") + rescue Chef::Exceptions::Exec + end + end + + def macterm + begin + require 'appscript' + rescue LoadError + STDERR.puts "you need the rb-appscript gem to use knife ssh macterm. `(sudo) gem install rb-appscript` to install" + raise + end + + Appscript.app("/Applications/Utilities/Terminal.app").windows.first.activate + Appscript.app("System Events").application_processes["Terminal.app"].keystroke("n", :using=>:command_down) + term = Appscript.app('Terminal') + window = term.windows.first.get + + (session.servers_for.size - 1).times do |i| + window.activate + Appscript.app("System Events").application_processes["Terminal.app"].keystroke("t", :using=>:command_down) + end + + session.servers_for.each_with_index do |server, tab_number| + cmd = "unset PROMPT_COMMAND; echo -e \"\\033]0;#{server.host}\\007\"; ssh #{server.user ? "#{server.user}@#{server.host}" : server.host}" + Appscript.app('Terminal').do_script(cmd, :in => window.tabs[tab_number + 1].get) + end + end + + def configure_attribute + # Setting 'knife[:ssh_attribute] = "foo"' in knife.rb => Chef::Config[:knife][:ssh_attribute] == 'foo' + # Running 'knife ssh -a foo' => both Chef::Config[:knife][:ssh_attribute] && config[:attribute] == foo + # Thus we can differentiate between a config file value and a command line override at this point by checking config[:attribute] + # We can tell here if fqdn was passed from the command line, rather than being the default, by checking config[:attribute] + # However, after here, we cannot tell these things, so we must preserve config[:attribute] + config[:override_attribute] = config[:attribute] || Chef::Config[:knife][:ssh_attribute] + config[:attribute] = (Chef::Config[:knife][:ssh_attribute] || + config[:attribute] || + "fqdn").strip + end + + def cssh + cssh_cmd = nil + %w[csshX cssh].each do |cmd| + begin + # Unix and Mac only + cssh_cmd = shell_out!("which #{cmd}").stdout.strip + break + rescue Mixlib::ShellOut::ShellCommandFailed + end + end + raise Chef::Exceptions::Exec, "no command found for cssh" unless cssh_cmd + + session.servers_for.each do |server| + cssh_cmd << " #{server.user ? "#{server.user}@#{server.host}" : server.host}" + end + Chef::Log.debug("starting cssh session with command: #{cssh_cmd}") + exec(cssh_cmd) + end + + def get_stripped_unfrozen_value(value) + return nil if value.nil? + value.strip + end + + def configure_user + config[:ssh_user] = get_stripped_unfrozen_value(config[:ssh_user] || + Chef::Config[:knife][:ssh_user]) + end + + def configure_identity_file + config[:identity_file] = get_stripped_unfrozen_value(config[:identity_file] || + Chef::Config[:knife][:ssh_identity_file]) + end + + def extract_nested_value(data_structure, path_spec) + ui.presenter.extract_nested_value(data_structure, path_spec) + end + + def run + extend Chef::Mixin::Command + + @longest = 0 + + configure_attribute + configure_user + configure_identity_file + configure_session + + exit_status = + case @name_args[1] + when "interactive" + interactive + when "screen" + screen + when "tmux" + tmux + when "macterm" + macterm + when "cssh" + cssh + when "csshx" + Chef::Log.warn("knife ssh csshx will be deprecated in a future release") + Chef::Log.warn("please use knife ssh cssh instead") + cssh + else + ssh_command(@name_args[1..-1].join(" ")) + end + + session.close + exit_status + end + + end + end +end |