diff --git a/lib/vagrant/action/builtin.rb b/lib/vagrant/action/builtin.rb index 2941263ef..fe0e38c43 100644 --- a/lib/vagrant/action/builtin.rb +++ b/lib/vagrant/action/builtin.rb @@ -8,6 +8,7 @@ module Vagrant up = Builder.new do use VM::Import use VM::Customize + use VM::ForwardPorts end register :up, up diff --git a/lib/vagrant/action/vm/forward_ports.rb b/lib/vagrant/action/vm/forward_ports.rb new file mode 100644 index 000000000..804803bdb --- /dev/null +++ b/lib/vagrant/action/vm/forward_ports.rb @@ -0,0 +1,145 @@ +module Vagrant + class Action + module VM + class ForwardPorts + def initialize(app,env) + @app = app + @env = env + + external_collision_check + end + + #-------------------------------------------------------------- + # Prepare Helpers - These functions are not involved in actually + # executing the action + #-------------------------------------------------------------- + + # This method checks for any port collisions with any VMs + # which are already created (by Vagrant or otherwise). + # report the collisions detected or will attempt to fix them + # automatically if the port is configured to do so. + def external_collision_check + existing = used_ports + @env.env.config.vm.forwarded_ports.each do |name, options| + if existing.include?(options[:hostport].to_s) + handle_collision(name, options, existing) + end + end + end + + # Handles any collisions. This method will either attempt to + # fix the collision automatically or will raise an error if + # auto fixing is disabled. + def handle_collision(name, options, existing_ports) + if !options[:auto] + # Auto fixing is disabled for this port forward, so we + # must throw an error so the user can fix it. + return @env.error!(:vm_port_collision, :name => name, :hostport => options[:hostport].to_s, :guestport => options[:guestport].to_s, :adapter => options[:adapter]) + end + + # Get the auto port range and get rid of the used ports and + # ports which are being used in other forwards so we're just + # left with available ports. + range = @env.env.config.vm.auto_port_range.to_a + range -= @env.env.config.vm.forwarded_ports.collect { |n, o| o[:hostport].to_i } + range -= existing_ports + + if range.empty? + return @env.error!(:vm_port_auto_empty, :vm_name => @env["vm"].name, :name => name, :options => options) + end + + # Set the port up to be the first one and add that port to + # the used list. + options[:hostport] = range.shift + existing_ports << options[:hostport] + + # Notify the user + @env.logger.info "Fixed port collision: #{name} now on port #{options[:hostport]}" + end + + #-------------------------------------------------------------- + # Execution + #-------------------------------------------------------------- + def call(env) + @env = env + + clear + forward_ports + + @app.call(env) + end + + def clear + if used_ports.length > 0 + @env.logger.info "Deleting any previously set forwarded ports..." + clear_ports + @env["vm"].reload! + end + end + + def forward_ports + @env.logger.info "Forwarding ports..." + + @env.env.config.vm.forwarded_ports.each do |name, options| + adapter = options[:adapter] + + # Assuming the only reason to establish port forwarding is because the VM is using Virtualbox NAT networking. + # Host-only or Bridged networking don't require port-forwarding and establishing forwarded ports on these + # attachment types has uncertain behaviour. + if @env["vm"].vm.network_adapters[adapter].attachment_type == :nat + @env.logger.info "Forwarding \"#{name}\": #{options[:guestport]} on adapter \##{adapter+1} => #{options[:hostport]}" + forward_port(name, options) + else + @env.logger.info "VirtualBox adapter \##{adapter+1} not configured as \"NAT\"." + @env.logger.info "Skipped port forwarding \"#{name}\": #{options[:guestport]} on adapter\##{adapter+1} => #{options[:hostport]}" + end + end + + @env["vm"].vm.save + @env["vm"].reload! + end + + #-------------------------------------------------------------- + # General Helpers + #-------------------------------------------------------------- + + # Returns an array of used ports. This method is implemented + # differently depending on the VirtualBox version, but the + # behavior is the same. + # + # @return [Array] + def used_ports + result = VirtualBox::VM.all.collect do |vm| + if vm.running? && vm.uuid != @env["vm"].uuid + vm.network_adapters.collect do |na| + na.nat_driver.forwarded_ports.collect do |fp| + fp.hostport.to_s + end + end + end + end + + result.flatten.uniq + end + + # Deletes existing forwarded ports. + def clear_ports + @env["vm"].vm.network_adapters.each do |na| + na.nat_driver.forwarded_ports.dup.each do |fp| + fp.destroy + end + end + end + + # Forwards a port. + def forward_port(name, options) + port = VirtualBox::NATForwardedPort.new + port.name = name + port.guestport = options[:guestport] + port.hostport = options[:hostport] + @env["vm"].vm.network_adapters[options[:adapter]].nat_driver.forwarded_ports << port + end + end + end + end +end diff --git a/test/vagrant/action/vm/forward_ports_test.rb b/test/vagrant/action/vm/forward_ports_test.rb new file mode 100644 index 000000000..94490517d --- /dev/null +++ b/test/vagrant/action/vm/forward_ports_test.rb @@ -0,0 +1,270 @@ +require File.join(File.dirname(__FILE__), '..', '..', '..', 'test_helper') + +class ForwardPortsVMActionTest < Test::Unit::TestCase + setup do + @klass = Vagrant::Action::VM::ForwardPorts + @app, @env = mock_action_data + + @vm = mock("vm") + @vm.stubs(:name).returns("foo") + @env["vm"] = @vm + end + + context "initializing" do + should "call proper methods" do + @klass.any_instance.expects(:external_collision_check) + @klass.new(@app, @env) + end + end + + context "checking for colliding external ports" do + setup do + @env.env.config.vm.forwarded_ports.clear + @env.env.config.vm.forward_port("ssh", 22, 2222) + + @used_ports = [] + @klass.any_instance.stubs(:used_ports).returns(@used_ports) + @klass.any_instance.stubs(:handle_collision) + end + + should "not raise any errors if no forwarded ports collide" do + @used_ports << "80" + @klass.new(@app, @env) + assert !@env.error? + end + + should "handle collision if it happens" do + @used_ports << "2222" + @klass.any_instance.expects(:handle_collision).with("ssh", anything, anything).once + @klass.new(@app, @env) + assert !@env.error? + end + end + + context "with instance" do + setup do + @klass.any_instance.stubs(:external_collision_check) + @instance = @klass.new(@app, @env) + end + + context "handling collisions" do + setup do + @name = :foo + @options = { + :hostport => 0, + :auto => true + } + @used_ports = [1,2,3] + + @env.env.config.vm.auto_port_range = (1..5) + end + + should "error if auto forwarding is disabled" do + @options[:auto] = false + @instance.handle_collision(@name, @options, @used_ports) + assert @env.error? + assert_equal :vm_port_collision, @env.error.first + end + + should "set the host port to the first available port" do + assert_equal 0, @options[:hostport] + @instance.handle_collision(@name, @options, @used_ports) + assert_equal 4, @options[:hostport] + end + + should "add the newly used port to the list of used ports" do + assert !@used_ports.include?(4) + @instance.handle_collision(@name, @options, @used_ports) + assert @used_ports.include?(4) + end + + should "not use a host port which is being forwarded later" do + @env.env.config.vm.forward_port("http", 80, 4) + + assert_equal 0, @options[:hostport] + @instance.handle_collision(@name, @options, @used_ports) + assert_equal 5, @options[:hostport] + end + + should "raise an exception if there are no auto ports available" do + @env.env.config.vm.auto_port_range = (1..3) + @instance.handle_collision(@name, @options, @used_ports) + assert @env.error? + assert_equal :vm_port_auto_empty, @env.error.first + end + end + + context "calling" do + should "clear all previous ports and forward new ports" do + exec_seq = sequence("exec_seq") + @instance.expects(:clear).once.in_sequence(exec_seq) + @instance.expects(:forward_ports).once.in_sequence(exec_seq) + @app.expects(:call).once.with(@env).in_sequence(exec_seq) + @instance.call(@env) + end + end + + context "forwarding ports" do + setup do + @internal_vm = mock("internal_vm") + @vm.stubs(:vm).returns(@internal_vm) + end + + should "create a port forwarding for the VM" do + forwarded_ports = mock("forwarded_ports") + network_adapter = mock("network_adapter") + + @internal_vm.stubs(:network_adapters).returns([network_adapter]) + network_adapter.expects(:attachment_type).returns(:nat) + + @instance.expects(:forward_port).once + @internal_vm.expects(:save).once + @vm.expects(:reload!).once + @instance.forward_ports + end + + should "not port forward for non NAT interfaces" do + forwarded_ports = mock("forwarded_ports") + network_adapter = mock("network_adapter") + + @internal_vm.expects(:network_adapters).returns([network_adapter]) + network_adapter.expects(:attachment_type).returns(:host_only) + @internal_vm.expects(:save).once + @vm.expects(:reload!).once + @instance.forward_ports + end + end + + context "clearing forwarded ports" do + setup do + @instance.stubs(:used_ports).returns([:a]) + @instance.stubs(:clear_ports) + end + + should "call destroy on all forwarded ports" do + @instance.expects(:clear_ports).once + @vm.expects(:reload!) + @instance.clear + end + + should "do nothing if there are no forwarded ports" do + @instance.stubs(:used_ports).returns([]) + @vm.expects(:reload!).never + @instance.clear + end + end + + context "getting list of used ports" do + setup do + @vms = [] + VirtualBox::VM.stubs(:all).returns(@vms) + VirtualBox.stubs(:version).returns("3.1.0") + @vm.stubs(:uuid).returns(:bar) + end + + def mock_vm(options={}) + options = { + :running? => true, + :uuid => :foo + }.merge(options) + + vm = mock("vm") + options.each do |k,v| + vm.stubs(k).returns(v) + end + + vm + end + + def mock_fp(hostport) + fp = mock("fp") + fp.stubs(:hostport).returns(hostport.to_s) + fp + end + + should "ignore VMs which aren't running" do + @vms << mock_vm(:running? => false) + @vms[0].expects(:forwarded_ports).never + @instance.used_ports + end + + should "ignore VMs of the same uuid" do + @vms << mock_vm(:uuid => @vm.uuid) + @vms[0].expects(:forwarded_ports).never + @instance.used_ports + end + + should "return the forwarded ports for VB 3.2.x" do + VirtualBox.stubs(:version).returns("3.2.4") + fps = [mock_fp(2222), mock_fp(80)] + na = mock("na") + ne = mock("ne") + na.stubs(:nat_driver).returns(ne) + ne.stubs(:forwarded_ports).returns(fps) + @vms << mock_vm(:network_adapters => [na]) + assert_equal %W[2222 80], @instance.used_ports + end + end + + context "clearing ports" do + def mock_fp + fp = mock("fp") + fp.expects(:destroy).once + fp + end + + setup do + VirtualBox.stubs(:version).returns("3.2.8") + @adapters = [] + @internal_vm = mock("internal_vm") + @internal_vm.stubs(:network_adapters).returns(@adapters) + @vm.stubs(:vm).returns(@internal_vm) + end + + def mock_adapter + na = mock("adapter") + engine = mock("engine") + engine.stubs(:forwarded_ports).returns([mock_fp]) + na.stubs(:nat_driver).returns(engine) + na + end + + should "destroy each forwarded port" do + @adapters << mock_adapter + @adapters << mock_adapter + @instance.clear_ports + end + end + + context "forwarding ports implementation" do + setup do + VirtualBox.stubs(:version).returns("3.2.8") + + @internal_vm = mock("internal_vm") + @vm.stubs(:vm).returns(@internal_vm) + end + + should "forward ports" do + name, opts = @env.env.config.vm.forwarded_ports.first + + adapters = [] + adapter = mock("adapter") + engine = mock("engine") + fps = mock("forwarded ports") + adapter.stubs(:nat_driver).returns(engine) + engine.stubs(:forwarded_ports).returns(fps) + fps.expects(:<<).with do |port| + assert_equal name, port.name + assert_equal opts[:hostport], port.hostport + assert_equal opts[:guestport], port.guestport + true + end + + adapters[opts[:adapter]] = adapter + @internal_vm.stubs(:network_adapters).returns(adapters) + + @instance.forward_port(name, opts) + end + end + end +end