-
Notifications
You must be signed in to change notification settings - Fork 254
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Allow SFTP to be used for upload!/download! instead of SCP #524
Conversation
@JasonPoll this PR is still a work in progress, but it should be ready for you to test. Let me know if this branch works with SFTP in your environment. Also would appreciate any suggestions/feedback on the implementation. Thanks! |
This will make it easier to swap in SFTP in a later commit.
5d98e4a
to
984b374
Compare
e75d894
to
84f822e
Compare
84f822e
to
0306386
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code looks reasonable to me. Haven't tried it (I don't tend to use these methods…).
e8cfbef
to
53ca44e
Compare
@@ -2,7 +2,6 @@ | |||
require 'strscan' | |||
require 'mutex_m' | |||
require 'net/ssh' | |||
require 'net/scp' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗒️ I moved this to the scp_transfer.rb
strategy, so that net/scp
is only loaded if that strategy is being used.
attr_writer :ssh_options | ||
|
||
def initialize | ||
self.transfer_method = :scp |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗒️ In a future release, we can consider changing the default to :sftp
(which would be a breaking change).
with_ssh do |ssh| | ||
ssh.scp.upload!(local, remote, options, &summarizer) | ||
with_transfer(summarizer) do |transfer| | ||
transfer.upload!(local, remote, options) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗒️ My goal with these changes was to leave upload!
, download!
and transfer_summarizer
code unmodified as much as possible. The difference is that rather than using ssh.scp
directly, the code now uses a transfer strategy provided by the new with_transfer
helper. The transfer strategy uses scp or sftp based on configuration.
In other words, the Netssh
class stays mostly the same, but the upload/download implementation can be swapped between scp and sftp.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As someone who works in a 10+ year old code base as part of my day-job, I appreciate the approach you've taken here -- minimal changes to the long-established core of the codebase, refactoring previous functionality into a sort of configuration-based 'plugin' design, and adding new functionality with that same plugin functionality, all the while retaining backwards compatibility.
💯, good sir.
@@ -105,7 +121,7 @@ def transfer_summarizer(action, options = {}) | |||
last_percentage = nil | |||
proc do |_ch, name, transferred, total| | |||
percentage = (transferred.to_f * 100 / total.to_f) | |||
unless percentage.nan? | |||
unless percentage.nan? || percentage.infinite? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗒️ To be safe, I added this check to guard against divide-by-zero.
@@ -183,6 +199,20 @@ def with_ssh(&block) | |||
) | |||
end | |||
|
|||
def with_transfer(summarizer) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗒️ This helper checks whether scp or sftp is configured (it can be specified globally or overridden per host), and instantiates and yields the appropriate transfer strategy class.
ssh.sftp.connect! | ||
ssh.sftp.upload!(local, remote, options) | ||
ensure | ||
ssh.sftp.close_channel |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗒️ We have to close the sftp channel when we are done. Otherwise subsequent ssh commands will hang.
|
||
def download!(remote, local, options) | ||
options = { progress: self }.merge(options || {}) | ||
destination = local ? local : StringIO.new.tap { |io| io.set_encoding('BINARY') } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗒️ This workaround is due to a surprising difference between net-scp and net-sftp. In net-scp, calling download!
without an explicit local path will return the download bytes as a binary-encoded string object.
However, net-sftp returns the string object as UTF-8 encoded. That means that binary files will be corrupted.
Work around this limitation by explicitly specifying a binary encoded StringIO
object, to make net-sftp be consistent with the behavior of net-scp.
def on_get(download, entry, offset, data) | ||
entry.size ||= download.sftp.file.open(entry.remote, &:size) | ||
summarizer.call(nil, entry.remote, offset + data.bytesize, entry.size) | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗒️ net-sftp calls on_get
to provide download progress. This information is much different than how net-scp provides progress. In net-sftp, offset
refers to the offset of the local destination, before the bytes have been transferred. So some arithmetic is needed to calculate the progress: offset + data.bytesize
.
Furthermore, the total size of the file being transferred, which is supposed to be provided via entry.size
, is nil
. To work around this, I had to explicitly make an sftp open
call to read the file size.
def on_put(_upload, file, offset, data) | ||
summarizer.call(nil, file.local, offset + data.bytesize, file.size) | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗒️ net-sftp calls on_put
to provide upload progress. For uploads, file.size
seems to be correct, so no workaround is needed.
|
||
module SSHKit | ||
module Backend | ||
module NetsshTransferTests |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗒️ I extracted the upload/download tests into a module, so the same tests can easily be reused for both scp and sftp.
lib/sshkit/backends/netssh.rb
Outdated
@@ -23,10 +22,27 @@ module SSHKit | |||
module Backend | |||
|
|||
class Netssh < Abstract | |||
def self.assert_valid_transfer_method!(method) | |||
return if [nil, :scp, :sftp].include?(method) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nil
?
I suppose it's inconsequential when transfer_method
defaults to :scp
along with the behaviors of #with_transfer
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@JasonPoll Host#transfer_method
is initially set to nil
(which means "use the global default"), so I wanted to make sure that nil
is an allowable value.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
...oh wait...I think I understand. nil
is considered a valid value because per-host configuration could be nil
and global configuration could be what is specifying :scp
or :sftp
.
Even in the case when both host and global configuration are assigned a transfer_value
of nil
, the #with_transfer
method still embodies the previous behavior of transfers being performed via SCP.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe a more correct implementation would be to have assert_valid_transfer_method!
strictly only allow :scp and :sftp, with a workaround in Host#transfer_method=
to allow nil
.
# netssh.rb
def self.assert_valid_transfer_method!(method)
return if [:scp, :sftp].include?(method)
raise ArgumentError, "#{method.inspect} is not a valid transfer method. Supported methods are :scp, :sftp."
end
# host.rb
def transfer_method=(method)
Backend::Netssh.assert_valid_transfer_method!(method) unless method.nil?
@transfer_method = method
end
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in 9bbcc2c
@@ -162,6 +162,23 @@ end | |||
In this case the `recursive: true` option mirrors the same options which are | |||
available to [`Net::{SCP,SFTP}`](http://net-ssh.github.io/net-scp/). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
confirmed this older documentation about recursive: true
is still true with SFTP enabled.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I performed some functionality tests with a couple VMs, one with SCP enabled, and one without. My tests performed as expected. Detailed copy/pasted notes below.
To keep my notes a bit more succinct, this is quick setup and testing method I used for testing
#upload!
and#download!
:require 'sshkit' require 'sshkit/dsl' include SSHKit::DSL def doit(ip, &block) puts "- global transfer method: :#{SSHKit::Backend::Netssh.config.transfer_method}" on [ip] do |host| yield host if block_given? as :vagrant do within '/home/vagrant/tmp' do upload! 'junk.dat', 'junk.dat' download! 'junk.dat', 'junk_downloaded.dat' end end end endTo further make my notes succinct, redundant lines are eliminated/ellipsed.
With everything set to SSHKit defaults, on 192.168.69.70
(a host with SCP enabled) uploading and downloading via SCP works as expected:
doit('192.168.69.70')
- global transfer method: :scp
INFO Uploading junk.dat 12.21%
...
INFO Uploading junk.dat 100.0%
INFO Downloading /home/vagrant/tmp/junk.dat 12.21%
...
INFO Downloading /home/vagrant/tmp/junk.dat 100.0%
With everything set to defaults, on 192.168.69.42
(a host with SCP disabled) it errors as expected when attempting to upload:
doit('192.168.69.42')
- global transfer method: :scp
#<Thread:0x00007fc2d022f1d8 /home/vagrant/code/sshkit/lib/sshkit/runners/parallel.rb:10 run> terminated with exception (report_on_exception is true):
...
SSHKit::Runner::ExecuteError: Exception while executing on host 192.168.69.42: SCP did not finish successfully (255):
from /home/vagrant/code/sshkit/lib/sshkit/runners/parallel.rb:15:in `rescue in block (2 levels) in execute'
Caused by Net::SCP::Error: SCP did not finish successfully (255):
from /home/vagrant/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/net-scp-4.0.0/lib/net/scp.rb:365:in `block (3 levels) in start_command'
Changing the global transfer_method
to :sftp
and the file uploads/downloads as expected:
SSHKit::Backend::Netssh.configure{|ssh| ssh.transfer_method = :sftp}
=> :sftp
doit('192.168.69.42')
- global transfer method: :sftp
INFO Uploading junk.dat 10.94%
...
INFO Uploading junk.dat 100.0%
INFO Downloading junk_downloaded.dat 10.94%
...
INFO Downloading junk_downloaded.dat 100.0%
Changing the transfer_method
to :sftp
on a per-host basis and the file uploads/downloads as expected:
doit('192.168.69.42') { |host| host.transfer_method = :sftp }
- global transfer method: :scp
INFO Uploading junk.dat 10.94%
...
INFO Uploading junk.dat 100.0%
INFO Downloading junk_downloaded.dat 10.94%
...
INFO Downloading junk_downloaded.dat 100.0%
This looks good to me!
Thanks @JasonPoll and @will-in-wi for the reviews! I plan on releasing this as part of sshkit 1.22.0 in mid-January, after 1.21.7 has some time in the wild. In the meantime, you can take advantage of the new sftp functionality by pointing your Gemfile at the master branch: gem "sshkit", github: "capistrano/sshkit", branch: "master" |
SSHKit can now use SFTP for file transfers. The default is still SCP, but this can be changed to SFTP per host:
or globally:
Fixes #15