diff --git a/lib/gems/pending/util/miq_ftp_lib.rb b/lib/gems/pending/util/miq_ftp_lib.rb new file mode 100644 index 000000000..ddada42d0 --- /dev/null +++ b/lib/gems/pending/util/miq_ftp_lib.rb @@ -0,0 +1,72 @@ +require 'net/ftp' + +# Helper methods for net/ftp based classes and files. +# +# Will setup a `@ftp` attr_accessor to be used as the return value for +# `.connect`, the main method being provided in this class. +module MiqFtpLib + def self.included(klass) + klass.send(:attr_accessor, :ftp) + end + + def connect(cred_hash = nil) + host = URI(uri).hostname + + begin + _log.info("Connecting to FTP host #{host_ref}...") + @ftp = Net::FTP.new(host) + # Use passive mode to avoid firewall issues see http://slacksite.com/other/ftp.html#passive + @ftp.passive = true + # @ftp.debug_mode = true if settings[:debug] # TODO: add debug option + creds = cred_hash ? [cred_hash[:username], cred_hash[:password]] : login_credentials + @ftp.login(*creds) + _log.info("Successfully connected FTP host #{host_ref}...") + rescue SocketError => err + _log.error("Failed to connect. #{err.message}") + raise + rescue Net::FTPPermError => err + _log.error("Failed to login. #{err.message}") + raise + else + @ftp + end + end + + def file_exists?(file_or_directory) + !ftp.nlst(file_or_directory.to_s).empty? + rescue Net::FTPPermError + false + end + + private + + def host_ref + return @host_ref if @host_ref + @host_ref = URI(uri).hostname + @host_ref << " (#{name})" if respond_to?(:name) + @host_ref + end + + def create_directory_structure(directory_path) + pwd = ftp.pwd + directory_path.to_s.split('/').each do |directory| + unless ftp.nlst.include?(directory) + _log.info("creating #{directory}") + ftp.mkdir(directory) + end + ftp.chdir(directory) + end + ftp.chdir(pwd) + end + + def with_connection(cred_hash = nil) + raise _("no block given") unless block_given? + _log.info("Connecting through #{self.class.name}: [#{host_ref}]") + begin + connect(cred_hash) + yield @ftp + ensure + @ftp.try(:close) && @ftp = nil + end + end +end diff --git a/manageiq-gems-pending.gemspec b/manageiq-gems-pending.gemspec index aa53396af..27d37f617 100644 --- a/manageiq-gems-pending.gemspec +++ b/manageiq-gems-pending.gemspec @@ -47,4 +47,5 @@ Gem::Specification.new do |s| s.add_development_dependency "test-unit" s.add_development_dependency "timecop", "~> 0.9.1" s.add_development_dependency "xml-simple", "~> 1.1.0" + s.add_development_dependency "ftpd", "~> 2.1.0" end diff --git a/spec/support/contexts/with_ftp_server.rb b/spec/support/contexts/with_ftp_server.rb new file mode 100644 index 000000000..0add97244 --- /dev/null +++ b/spec/support/contexts/with_ftp_server.rb @@ -0,0 +1,90 @@ +require "ftpd" +require "tmpdir" +require "tempfile" +require "fileutils" + +class FtpSingletonServer + class << self + attr_reader :driver + end + + def self.run_ftp_server + @driver = FTPServerDriver.new + @ftp_server = Ftpd::FtpServer.new(@driver) + @ftp_server.on_exception do |e| + STDOUT.puts e.inspect + end + @ftp_server.start + end + + def self.bound_port + @ftp_server.bound_port + end + + def self.stop_ftp_server + @ftp_server.stop + @ftp_server = nil + + @driver.cleanup + @driver = nil + end +end + +class FTPServerDriver + attr_reader :existing_file, :existing_dir + + def initialize + create_tmp_dir + end + + def authenticate(username, password) + username == "ftpuser" && password == "ftppass" + end + + def file_system(_user) + Ftpd::DiskFileSystem.new(@ftp_dir) + end + + def cleanup + FileUtils.remove_entry(@ftp_dir) + end + + def create_existing_file(size = 0) + @existing_file ||= Tempfile.new("", @ftp_dir).tap { |tmp| tmp.puts "0" * size } + end + + # Create a dir under the @ftp_dir, but only return the created directory name + def create_existing_dir + @existing_dir ||= Dir.mktmpdir(nil, @ftp_dir).sub("#{@ftp_dir}/", "") + end + + private + + def create_tmp_dir + @ftp_dir = Dir.mktmpdir + end +end + +shared_context "with ftp server", :with_ftp_server do + before(:all) { FtpSingletonServer.run_ftp_server } + after(:all) { FtpSingletonServer.stop_ftp_server } + + # HACK: Avoid permission denied errors with `ftpd` starting on port 21, but + # our FTP lib always assuming that we are using the default port + # + # The hack basically sets the default port for `Net::FTP` to the bound port + # of the running server + before(:each) do + stub_const("Net::FTP::FTP_PORT", FtpSingletonServer.bound_port) + end + + let(:valid_ftp_creds) { { :username => "ftpuser", :password => "ftppass" } } + + def existing_ftp_file(size = 0) + FtpSingletonServer.driver.create_existing_file(size) + end + + def existing_ftp_dir + FtpSingletonServer.driver.create_existing_dir + end +end diff --git a/spec/util/miq_ftp_lib_spec.rb b/spec/util/miq_ftp_lib_spec.rb new file mode 100644 index 000000000..21792c5d5 --- /dev/null +++ b/spec/util/miq_ftp_lib_spec.rb @@ -0,0 +1,193 @@ +require 'util/miq_ftp_lib' +require 'logger' # probably loaded elsewhere, but for the below classes + +class FTPKlass + include MiqFtpLib + + attr_accessor :uri + + def self.instance_logger + Logger.new(File::NULL) # null logger (for testing) + end + + private + + def _log + self.class.instance_logger + end +end + +class OtherFTPKlass + include MiqFtpLib + + attr_accessor :uri + + def _log + private_log_method + end + + private + + def private_log_method + Logger.new(File::NULL) # null logger (for testing) + end + + def login_credentials + %w(ftpuser ftppass) + end +end + +shared_examples "connecting" do |valid_cred_hash| + let(:cred_hash) { valid_cred_hash } + + before { subject.uri = "ftp://localhost" } + + it "logs in with valid credentials" do + expect { subject.connect(cred_hash) }.not_to raise_error + end + + it "sets the connection to passive" do + subject.connect(cred_hash) + expect(subject.ftp.passive).to eq(true) + end + + context "with an invalid ftp credentials" do + let(:cred_hash) { { :username => "invalid", :password => "alsoinvalid" } } + + it "raises a Net::FTPPermError" do + expect { subject.connect(cred_hash) }.to raise_error(Net::FTPPermError) + end + end +end + +shared_examples "with a connection" do |valid_cred_hash| + let(:cred_hash) { valid_cred_hash } + let(:error_msg) { "no block given" } + + before do + subject.uri = "ftp://localhost" + allow(subject).to receive(:_).with(error_msg).and_return(error_msg) + end + + def with_connection(&block) + subject.send(:with_connection, cred_hash, &block) + end + + def get_socket(ftp) + ftp.instance_variable_get(:@sock).instance_variable_get(:@io) + end + + it "passes the ftp object to the block" do + with_connection do |ftp| + expect(ftp).to be_a(Net::FTP) + expect(subject.ftp).to be(ftp) + end + end + + it "closes the ftp connection after the block is finished" do + ftp_instance = subject.connect(cred_hash) + # stub further calls to `#connect` + expect(subject).to receive(:connect).and_return(ftp_instance) + + with_connection { |ftp| } + expect(subject.ftp).to eq(nil) + expect(ftp_instance.closed?).to eq(true) + end + + it "raises an error if no block is given" do + expect { with_connection }.to raise_error(RuntimeError, error_msg) + end +end + +describe MiqFtpLib do + subject { FTPKlass.new } + + describe "when included" do + it "has a `ftp` accessor" do + ftp_instance = Net::FTP.new + subject.ftp = ftp_instance + + expect(subject.ftp).to eq ftp_instance + end + end + + describe "#connect", :with_ftp_server do + context "with credentials hash" do + subject { FTPKlass.new } + + include_examples "connecting", :username => "ftpuser", :password => "ftppass" + end + + context "with login_credentials method" do + subject { OtherFTPKlass.new } + + include_examples "connecting" + end + end + + describe "#with_connection", :with_ftp_server do + context "with credentials hash" do + subject { FTPKlass.new } + + include_examples "with a connection", :username => "ftpuser", :password => "ftppass" + end + + context "with login_credentials method" do + subject { OtherFTPKlass.new } + + include_examples "with a connection" + end + end + + describe "#file_exists?", :with_ftp_server do + let(:existing_file) { File.basename(existing_ftp_file) } + + subject { FTPKlass.new.tap { |ftp| ftp.uri = "ftp://localhost" } } + before { subject.connect(valid_ftp_creds) } + + it "returns true if the file exists" do + expect(subject.file_exists?(existing_file)).to eq(true) + end + + it "returns false if the file does not exist" do + expect(subject.file_exists?("#{existing_file}.fake")).to eq(false) + end + end + + # Note: Don't use `file_exists?` to try and test the directory existance. + # Most FTP implementations will send the results of `nlst` as the contents of + # a directory if a directory is given. + # + # In our current implementation, this will return a empty list if the + # directory is empty, thus causing the check to fail. Testing against the + # `ftp.nlst(parent_dir)` will make sure the directory in question is included + # in it's parent. + describe "#create_directory_structure", :with_ftp_server do + subject { OtherFTPKlass.new.tap { |ftp| ftp.uri = "ftp://localhost" } } + before { subject.connect(valid_ftp_creds) } + + it "creates a new nested directory" do + new_dir = "foo/bar/baz" + parent_dir = File.dirname(new_dir) + + expect(subject.ftp.nlst(parent_dir).include?("baz")).to eq(false) + subject.send(:create_directory_structure, new_dir) + expect(subject.ftp.nlst(parent_dir).include?("baz")).to eq(true) + end + + context "to an existing directory" do + it "creates the nested directory without messing with the existing" do + existing_dir = existing_ftp_dir + new_dir = File.join(existing_ftp_dir, "foo/bar/baz") + parent_dir = File.dirname(new_dir) + + expect(subject.ftp.nlst.include?(existing_dir)).to eq(true) + expect(subject.ftp.nlst(parent_dir).include?("baz")).to eq(false) + + subject.send(:create_directory_structure, new_dir) + expect(subject.ftp.nlst.include?(existing_dir)).to eq(true) + expect(subject.ftp.nlst(parent_dir).include?("baz")).to eq(true) + end + end + end +end