Skip to content
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

Merged
merged 11 commits into from
Dec 31, 2023

Conversation

mattbrictson
Copy link
Member

SSHKit can now use SFTP for file transfers. The default is still SCP, but this can be changed to SFTP per host:

host = SSHKit::Host.new('user@example.com')
host.transfer_method = :sftp

or globally:

SSHKit::Backend::Netssh.configure do |ssh|
  ssh.transfer_method = :sftp
end

Fixes #15

@mattbrictson mattbrictson added the ✨ Feature Adds a new feature label Dec 22, 2023
@mattbrictson
Copy link
Member Author

@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!

@mattbrictson mattbrictson force-pushed the features/sftp branch 2 times, most recently from e75d894 to 84f822e Compare December 22, 2023 21:22
Copy link
Contributor

@will-in-wi will-in-wi left a 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…).

@mattbrictson mattbrictson marked this pull request as ready for review December 27, 2023 21:42
@@ -2,7 +2,6 @@
require 'strscan'
require 'mutex_m'
require 'net/ssh'
require 'net/scp'
Copy link
Member Author

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
Copy link
Member Author

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).

Comment on lines -67 to +84
with_ssh do |ssh|
ssh.scp.upload!(local, remote, options, &summarizer)
with_transfer(summarizer) do |transfer|
transfer.upload!(local, remote, options)
Copy link
Member Author

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.

Copy link
Contributor

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?
Copy link
Member Author

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)
Copy link
Member Author

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
Copy link
Member Author

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') }
Copy link
Member Author

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.

Comment on lines +31 to +34
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
Copy link
Member Author

@mattbrictson mattbrictson Dec 27, 2023

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.

Comment on lines +36 to +38
def on_put(_upload, file, offset, data)
summarizer.call(nil, file.local, offset + data.bytesize, file.size)
end
Copy link
Member Author

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
Copy link
Member Author

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.

@@ -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)
Copy link
Contributor

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.

Copy link
Member Author

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.

Copy link
Contributor

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.

Copy link
Member Author

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

Copy link
Member Author

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/).
Copy link
Contributor

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.

Copy link
Contributor

@JasonPoll JasonPoll left a 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 
 end 

To 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!

@mattbrictson
Copy link
Member Author

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"

@mattbrictson mattbrictson merged commit 2ab75aa into master Dec 31, 2023
16 checks passed
@mattbrictson mattbrictson deleted the features/sftp branch December 31, 2023 16:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
✨ Feature Adds a new feature
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Allow to choose between sftp and scp.
3 participants