diff --git a/plugins/pushes/ftp/push.rb b/plugins/pushes/ftp/push.rb new file mode 100644 index 000000000..3cf4169a0 --- /dev/null +++ b/plugins/pushes/ftp/push.rb @@ -0,0 +1,116 @@ +require "net/ftp" +require "pathname" + +require_relative "adapter" + +module VagrantPlugins + module FTPPush + class Push < Vagrant.plugin("2", :push) + IGNORED_FILES = %w(. ..).freeze + + def push + # Grab files early so if there's an exception or issue, we don't have to + # wait and close the (S)FTP connection as well + files = Hash[*all_files.flat_map do |file| + relative_path = relative_path_for(file, config.dir) + destination = File.expand_path(File.join(config.destination, relative_path)) + [file, destination] + end] + + connect do |ftp| + files.each do |local, remote| + ftp.upload(local, remote) + end + end + end + + # Helper method for creating the FTP or SFTP connection. + # @yield [Adapter] + def connect(&block) + klass = config.secure ? SFTPAdapter : FTPAdapter + ftp = klass.new(config.host, config.username, config.password, + passive: config.passive) + ftp.connect(&block) + end + + # Parse the host into it's url and port parts. + # @return [Array] + def parse_host(host) + if host.include?(":") + host.split(":", 2) + else + [host, "22"] + end + end + + # The list of all files that should be pushed by this push. This method + # only returns **files**, not folders or symlinks! + # @return [Array] + def all_files + files = glob("#{config.dir}/**/*") + includes_files + filter_excludes!(files, config.excludes) + files.reject! { |f| !File.file?(f) } + files + end + + # The list of files to include in addition to those specified in `dir`. + # @return [Array] + def includes_files + includes = config.includes.flat_map do |i| + path = absolute_path_for(i, config.dir) + [path, "#{path}/**/*"] + end + + glob("{#{includes.join(",")}}") + end + + # Filter the excludes out of the given list. This method modifies the + # given list in memory! + # + # @param [Array] list + # the filepaths + # @param [Array] excludes + # the exclude patterns or files + def filter_excludes!(list, excludes) + excludes = Array(excludes).flat_map { |e| [e, "#{e}/*"] } + list.reject! do |file| + basename = relative_path_for(file, config.dir) + + # Handle the special case where the file is outside of the working + # directory... + if basename.start_with?("../") + basename = file + end + + excludes.any? { |e| File.fnmatch?(e, basename, File::FNM_DOTMATCH) } + end + end + + # Get the list of files that match the given pattern. + # @return [Array] + def glob(pattern) + Dir.glob(pattern, File::FNM_DOTMATCH).sort.reject do |file| + IGNORED_FILES.include?(File.basename(file)) + end + end + + # The absolute path to the given `path` and `parent`, unless the given + # path is absolute. + # @return [String] + def absolute_path_for(path, parent) + path = Pathname.new(path) + return path if path.absolute? + File.expand_path(path, parent) + end + + # The relative path from the given `parent`. If files exist on another + # device, this will probably blow up. + # @return [String] + def relative_path_for(path, parent) + Pathname.new(path).relative_path_from(Pathname.new(parent)).to_s + rescue ArgumentError + return path + end + end + end +end diff --git a/test/unit/plugins/pushes/ftp/push_test.rb b/test/unit/plugins/pushes/ftp/push_test.rb new file mode 100644 index 000000000..ca8ee2752 --- /dev/null +++ b/test/unit/plugins/pushes/ftp/push_test.rb @@ -0,0 +1,313 @@ +require_relative "../../../base" +require "fake_ftp" + +require Vagrant.source_root.join("plugins/pushes/ftp/push") + +describe VagrantPlugins::FTPPush::Push do + include_context "unit" + + let(:env) { isolated_environment } + let(:config) do + double("config", + host: "127.0.0.1:21212", + username: "sethvargo", + password: "bacon", + passive: false, + secure: false, + destination: "/var/www/site", + ) + end + + subject { described_class.new(env, config) } + + describe "#push" do + before(:all) do + @server = FakeFtp::Server.new(21212, 21213) + @server.start + + @dir = Dir.mktmpdir + + FileUtils.touch("#{@dir}/.hidden.rb") + FileUtils.touch("#{@dir}/application.rb") + FileUtils.touch("#{@dir}/config.rb") + FileUtils.touch("#{@dir}/Gemfile") + FileUtils.touch("#{@dir}/data.txt") + FileUtils.mkdir("#{@dir}/empty_folder") + end + + after(:all) do + FileUtils.rm_rf(@dir) + @server.stop + end + + let(:server) { @server } + + before do + allow(config).to receive(:dir) + .and_return(@dir) + + allow(config).to receive(:includes) + .and_return([]) + + allow(config).to receive(:excludes) + .and_return(%w(*.rb)) + end + + + it "pushes the files to the server" do + subject.push + expect(server.files).to eq(%w(Gemfile data.txt)) + end + end + + describe "#connect" do + before do + allow_any_instance_of(VagrantPlugins::FTPPush::FTPAdapter) + .to receive(:connect) + .and_yield(:ftp) + allow_any_instance_of(VagrantPlugins::FTPPush::SFTPAdapter) + .to receive(:connect) + .and_yield(:sftp) + end + + context "when secure is requested" do + before do + allow(config).to receive(:secure) + .and_return(true) + end + + it "yields a new SFTPAdapter" do + expect { |b| subject.connect(&b) }.to yield_with_args(:sftp) + end + end + + context "when secure is not requested" do + before do + allow(config).to receive(:secure) + .and_return(false) + end + + it "yields a new FTPAdapter" do + expect { |b| subject.connect(&b) }.to yield_with_args(:ftp) + end + end + end + + describe "#parse_host" do + let(:result) { subject.parse_host(host) } + + context "when no port is given" do + let(:host) { "127.0.0.1" } + + it "returns the url and port 22" do + expect(result).to eq(["127.0.0.1", "22"]) + end + end + + context "when a port is given" do + let(:host) { "127.0.0.1:23456" } + + it "returns the url and port 23456" do + expect(result).to eq(["127.0.0.1", "23456"]) + end + end + + context "when more than more port is given" do + let(:host) { "127.0.0.1:22:33:44" } + + it "returns the url and everything after" do + expect(result).to eq(["127.0.0.1", "22:33:44"]) + end + end + end + + describe "#all_files" do + before(:all) do + @dir = Dir.mktmpdir + + FileUtils.touch("#{@dir}/.hidden.rb") + FileUtils.touch("#{@dir}/application.rb") + FileUtils.touch("#{@dir}/config.rb") + FileUtils.touch("#{@dir}/Gemfile") + FileUtils.mkdir("#{@dir}/empty_folder") + FileUtils.mkdir("#{@dir}/folder") + FileUtils.mkdir("#{@dir}/folder/.git") + FileUtils.touch("#{@dir}/folder/.git/config") + FileUtils.touch("#{@dir}/folder/server.rb") + end + + after(:all) do + FileUtils.rm_rf(@dir) + end + + let(:files) do + subject.all_files.map do |file| + file.sub("#{@dir}/", "") + end + end + + before do + allow(config).to receive(:dir) + .and_return(@dir) + + allow(config).to receive(:includes) + .and_return(%w(not_a_file.rb still_not_a_file.rb)) + + allow(config).to receive(:excludes) + .and_return(%w(*.rb)) + end + + it "returns the list of real files + includes, without excludes" do + expect(files).to eq(%w( + Gemfile + folder/.git/config + )) + end + end + + describe "includes_files" do + before(:all) do + @dir = Dir.mktmpdir + + FileUtils.touch("#{@dir}/.hidden.rb") + FileUtils.touch("#{@dir}/application.rb") + FileUtils.touch("#{@dir}/config.rb") + FileUtils.touch("#{@dir}/Gemfile") + FileUtils.mkdir("#{@dir}/folder") + FileUtils.mkdir("#{@dir}/folder/.git") + FileUtils.touch("#{@dir}/folder/.git/config") + FileUtils.touch("#{@dir}/folder/server.rb") + end + + after(:all) do + FileUtils.rm_rf(@dir) + end + + let(:files) do + subject.includes_files.map do |file| + file.sub("#{@dir}/", "") + end + end + + before do + allow(config).to receive(:dir) + .and_return(@dir) + end + + def set_includes(value) + allow(config).to receive(:includes) + .and_return(value) + end + + it "includes the file" do + set_includes(["Gemfile"]) + expect(files).to eq(%w( + Gemfile + )) + end + + it "includes the files that are subdirectories" do + set_includes(["folder"]) + expect(files).to eq(%w( + folder + folder/.git + folder/.git/config + folder/server.rb + )) + end + + it "includes files that match a pattern" do + set_includes(["*.rb"]) + expect(files).to eq(%w( + .hidden.rb + application.rb + config.rb + )) + end + end + + describe "#filter_excludes" do + let(:dir) { "/root/dir" } + + let(:list) do + %W( + #{dir}/.hidden.rb + #{dir}/application.rb + #{dir}/config.rb + #{dir}/Gemfile + #{dir}/folder + #{dir}/folder/.git + #{dir}/folder/.git/config + #{dir}/folder/server.rb + + /path/outside/you.rb + /path/outside/me.rb + /path/outside/folder/bacon.rb + ) + end + + before do + allow(config).to receive(:dir) + .and_return(dir) + end + + it "excludes files" do + subject.filter_excludes!(list, %w(*.rb)) + + expect(list).to eq(%W( + #{dir}/Gemfile + #{dir}/folder + #{dir}/folder/.git + #{dir}/folder/.git/config + )) + end + + it "excludes files in a directory" do + subject.filter_excludes!(list, %w(folder)) + + expect(list).to eq(%W( + #{dir}/.hidden.rb + #{dir}/application.rb + #{dir}/config.rb + #{dir}/Gemfile + + /path/outside/you.rb + /path/outside/me.rb + /path/outside/folder/bacon.rb + )) + end + + it "excludes specific files in a directory" do + subject.filter_excludes!(list, %w(/path/outside/folder/*.rb)) + + expect(list).to eq(%W( + #{dir}/.hidden.rb + #{dir}/application.rb + #{dir}/config.rb + #{dir}/Gemfile + #{dir}/folder + #{dir}/folder/.git + #{dir}/folder/.git/config + #{dir}/folder/server.rb + + /path/outside/you.rb + /path/outside/me.rb + )) + end + + it "excludes files outside the #dir" do + subject.filter_excludes!(list, %w(/path/outside)) + + expect(list).to eq(%W( + #{dir}/.hidden.rb + #{dir}/application.rb + #{dir}/config.rb + #{dir}/Gemfile + #{dir}/folder + #{dir}/folder/.git + #{dir}/folder/.git/config + #{dir}/folder/server.rb + )) + end + end +end