Bug: allow `leap test --continue` to run on additional nodes if there was an ssh...
[leap_platform.git] / lib / leap_cli / ssh / backend.rb
1 #
2 # A custome SSHKit backend, derived from the default netssh backend.
3 # Our custom backend modifies the logging behavior and gracefully captures
4 # common exceptions.
5 #
6
7 require 'stringio'
8 require 'timeout'
9 require 'sshkit'
10 require 'leap_cli/ssh/formatter'
11 require 'leap_cli/ssh/scripts'
12
13 module SSHKit
14   class Command
15     #
16     # override exit_status in order to be less verbose
17     #
18     def exit_status=(new_exit_status)
19       @finished_at = Time.now
20       @exit_status = new_exit_status
21       if options[:raise_on_non_zero_exit] && exit_status > 0
22         message = ""
23         message += "exit status: " + exit_status.to_s + "\n"
24         message += "stdout: " + (full_stdout.strip.empty? ? "Nothing written" : full_stdout.strip) + "\n"
25         message += "stderr: " + (full_stderr.strip.empty? ? 'Nothing written' : full_stderr.strip) + "\n"
26         raise Failed, message
27       end
28     end
29   end
30 end
31
32 module LeapCli
33   module SSH
34     class Backend < SSHKit::Backend::Netssh
35
36       # since the @pool is a class instance variable, we need to copy
37       # the code from the superclass that initializes it. boo
38       @pool = SSHKit::Backend::ConnectionPool.new
39
40       # modify to pass itself to the block, instead of relying on instance_exec.
41       def run
42         Thread.current["sshkit_backend"] = self
43         # was: instance_exec(@host, &@block)
44         @block.call(self, @host)
45       ensure
46         Thread.current["sshkit_backend"] = nil
47       end
48
49       # if set, all the commands will begin with:
50       # sudo -u #{@user} -- sh -c '<command>'
51       def set_user(user='root')
52         @user = user
53       end
54
55       #
56       # like default capture, but gracefully logs failures for us
57       # last argument can be an options hash.
58       #
59       # available options:
60       #
61       #   :fail_msg    - [nil] if set, log this instead of the default
62       #                  fail message.
63       #
64       #   :raise_error - [nil] if true, then reraise failed command exception.
65       #
66       #   :log_cmd     - [false] if true, log what the command is that gets run.
67       #
68       #   :log_output  - [true] if true, log each output from the command as
69       #                  it is received.
70       #
71       #   :log_finish  - [false] if true, log the exit status and time
72       #                  to completion
73       #
74       #   :log_wrap    - [nil] passed to log method as :wrap option.
75       #
76       def capture(*args)
77         extract_options(args)
78         initialize_logger(:log_output => false)
79         rescue_ssh_errors(*args) do
80           return super(*args)
81         end
82       end
83
84       #
85       # like default execute, but log the results as they come in.
86       #
87       # see capture() for available options
88       #
89       def stream(*args)
90         extract_options(args)
91         initialize_logger
92         rescue_ssh_errors(*args) do
93           execute(*args)
94         end
95       end
96
97       def log(*args, &block)
98         @logger ||= LeapCli.new_logger
99         @logger.log(*args, &block)
100       end
101
102       # some prewritten servers-side scripts
103       def scripts
104         @scripts ||= LeapCli::SSH::Scripts.new(self, @host.hostname)
105       end
106
107       #
108       # sshkit just passes upload! and download! to Net::SCP, but Net::SCP
109       # make it impossible to set the file permissions. Here is how the mode
110       # is determined, from upload.rb:
111       #
112       #    mode = channel[:stat] ? channel[:stat].mode & 07777 : channel[:options][:mode]
113       #
114       # The stat info from the file always overrides the mode you pass in options.
115       # However, the channel[:options][:mode] will be applied for pure in-memory
116       # uploads. So, if the mode is set, we convert the upload to be a memory
117       # upload instead of a file upload.
118       #
119       # Stupid, but blame Net::SCP.
120       #
121       def upload!(src, dest, options={})
122         if options[:mode]
123           if src.is_a?(StringIO)
124             content = src
125           else
126             content = StringIO.new(File.read(src))
127           end
128           super(content, dest, options)
129         else
130           super(src, dest, options)
131         end
132       end
133
134       private
135
136       #
137       # creates a new logger instance for this specific ssh command.
138       # by doing this, each ssh session has its own logger and its own
139       # indentation.
140       #
141       # potentially modifies 'args' array argument.
142       #
143       def initialize_logger(default_options={})
144         @logger ||= LeapCli.new_logger
145         @output = LeapCli::SSH::Formatter.new(@logger, @host, default_options.merge(@options))
146       end
147
148       def extract_options(args)
149         if args.last.is_a? Hash
150           @options = args.pop
151         else
152           @options = {}
153         end
154       end
155
156       #
157       # capture common exceptions
158       #
159       def rescue_ssh_errors(*args, &block)
160         yield
161       rescue Net::SSH::HostKeyMismatch => exc
162         @logger.log(:fatal_error, "Host key mismatch!") do
163           @logger.log(exc.to_s)
164           @logger.log("The ssh host key for the server does not match what is on "+
165             " file in `%s`." % Path.named_path(:known_hosts))
166           @logger.log("One of these is happening:") do
167             @logger.log("There is an active Man in The Middle attack against you.")
168             @logger.log("Or, someone has generated new host keys for the server " +
169                "and your provider files are out of date.")
170             @logger.log("Or, a new server is using this IP address " +
171                "and your provider files are out of date.")
172             @logger.log("Or, the server configuration has changed to use a different host key.")
173           end
174           @logger.log("You can pin a different host key using `leap node init NODE`, " +
175             "but you must verify the fingerprint of the new host key!")
176         end
177         exit(1)
178       rescue StandardError => exc
179         if exc.is_a?(SSHKit::Command::Failed) || exc.is_a?(SSHKit::Runner::ExecuteError)
180           if @options[:raise_error]
181             raise exc
182           elsif @options[:fail_msg]
183             @logger.log(@options[:fail_msg], host: @host.hostname, :color => :red)
184           else
185             @logger.log(:failed, args.join(' '), host: @host.hostname) do
186               @logger.log(exc.to_s.strip, wrap: true)
187             end
188           end
189         elsif exc.is_a?(Timeout::Error) || exc.is_a?(Net::SSH::ConnectionTimeout)
190           @logger.log(:failed, args.join(' '), host: @host.hostname) do
191             @logger.log("Connection timed out")
192           end
193           if @options[:raise_error]
194             raise LeapCli::SSH::TimeoutError, exc.to_s
195           end
196         else
197           raise
198         end
199         return nil
200       end
201
202       def output
203         @output ||= LeapCli::SSH::Formatter.new(@logger, @host)
204       end
205
206     end
207   end
208 end
209