diff --git a/lib/fog/libvirt/models/compute/server.rb b/lib/fog/libvirt/models/compute/server.rb index f2bc85e..0d2ec7e 100644 --- a/lib/fog/libvirt/models/compute/server.rb +++ b/lib/fog/libvirt/models/compute/server.rb @@ -259,11 +259,14 @@ def generate_config_iso_in_dir(dir_path, user_data, &blk) user_data_path = File.join(dir_path, 'user-data') File.write(user_data_path, user_data) - isofile = Tempfile.new(['init', '.iso']).path + iso_tempfile = Tempfile.new(['init', '.iso']) + isofile = iso_tempfile.path unless system('xorrisofs', '-output', isofile, '-volid', 'cidata', '-joliet', '-rock', user_data_path, meta_data_path) raise Fog::Errors::Error.new("Couldn't generate cloud-init iso disk with xorrisofs.") end blk.call(isofile) + ensure + iso_tempfile&.close! end def create_user_data_iso @@ -472,6 +475,46 @@ def read_ceph_args(path = "/etc/foreman/ceph.conf") args end + DNSMASQ_LEASE_DIR = '/var/lib/libvirt/dnsmasq'.freeze + + # Read IP from a dnsmasq lease file when the libvirt DHCPLeases API + # returns nothing. This happens when DHCP is provided by an external + # dnsmasq (not started by libvirt) -- for example, a dnsmasq running + # inside a network namespace to work around port conflicts on WSL2. + # + # dnsmasq lease file format (space-separated): + # [] + def ip_address_from_leasefile(net, mac) + net_name = net.name + return nil unless net_name + + lease_file = File.join(DNSMASQ_LEASE_DIR, "#{net_name}.leases") + return nil unless File.exist?(lease_file) + + target_mac = mac.to_s.downcase + best_expiry = 0 + best_ip = nil + + begin + File.foreach(lease_file) do |line| + parts = line.strip.split + next unless parts.length >= 4 + + expiry = parts[0].to_i + lease_mac = parts[1].downcase + ip = parts[2] + if lease_mac == target_mac && expiry > best_expiry + best_expiry = expiry + best_ip = ip + end + end + rescue Errno::EACCES, Errno::ENOENT + return nil + end + + best_ip + end + # This retrieves the ip address of the mac address using dhcp_leases # It returns an array of public and private ip addresses # Currently only one ip address is returned, but in the future this could be multiple @@ -482,13 +525,32 @@ def addresses(service_arg=service, options={}) net = service.networks.all(:name => nic.network).first # Assume the lease expiring last is the current IP address ip_address = net&.dhcp_leases(nic.mac)&.max_by { |lse| lse["expirytime"] }&.dig("ipaddr") + + # Fallback: when the network has no libvirt-managed DHCP (e.g. an + # external dnsmasq running in a network namespace on WSL2), the + # DHCPLeases API returns empty. Read the dnsmasq lease file directly. + # + # Only attempt this for a local connection: the lease file lives on + # the libvirt host's filesystem, so for a remote URI (e.g. qemu+ssh) + # we would be reading the client machine's files instead. + if ip_address.nil? && net && !service.uri.remote? + warn_leasefile_fallback(nic.mac) + ip_address = ip_address_from_leasefile(net, nic.mac) + end end return { :public => [ip_address], :private => [ip_address] } end - # Locale-friendly removal of non-alpha nums - DOMAIN_CLEANUP_REGEXP = Regexp.compile('[\W_-]') + # Warn once per MAC (per server instance) when falling back to the + # dnsmasq lease file, to avoid log spam on repeated address lookups. + def warn_leasefile_fallback(mac) + @leasefile_warned_macs ||= {} + return if @leasefile_warned_macs[mac] + + Fog::Logger.warning("DHCPLeases API returned no address for #{mac}; falling back to dnsmasq lease file.") + @leasefile_warned_macs[mac] = true + end def ip_address(key) addresses[key]&.first diff --git a/minitests/server/leasefile_fallback_test.rb b/minitests/server/leasefile_fallback_test.rb new file mode 100644 index 0000000..3f482c3 --- /dev/null +++ b/minitests/server/leasefile_fallback_test.rb @@ -0,0 +1,113 @@ +require 'test_helper' +require 'tmpdir' + +class LeasefileFallbackTest < Minitest::Test + def setup + @compute = Fog::Compute[:libvirt] + @server = @compute.servers.new(:name => "test") + @mac = "52:54:00:01:02:03" + @net = stub(:name => "default") + @tmpdir = Dir.mktmpdir("fog-leasefile-test") + @original_dir = Fog::Libvirt::Compute::Server::DNSMASQ_LEASE_DIR + Fog::Libvirt::Compute::Server.send(:remove_const, :DNSMASQ_LEASE_DIR) + Fog::Libvirt::Compute::Server.const_set(:DNSMASQ_LEASE_DIR, @tmpdir) + end + + def teardown + FileUtils.remove_entry(@tmpdir) + Fog::Libvirt::Compute::Server.send(:remove_const, :DNSMASQ_LEASE_DIR) + Fog::Libvirt::Compute::Server.const_set(:DNSMASQ_LEASE_DIR, @original_dir) + end + + def test_returns_ip_for_matching_mac + write_lease_file("default", "1000 52:54:00:01:02:03 192.168.122.10 host1 *\n") + result = @server.send(:ip_address_from_leasefile, @net, @mac) + assert_equal "192.168.122.10", result + end + + def test_returns_ip_with_highest_expiry + content = <<~LEASES + 1000 52:54:00:01:02:03 192.168.122.10 host1 * + 2000 52:54:00:01:02:03 192.168.122.20 host1 * + 1500 52:54:00:01:02:03 192.168.122.15 host1 * + LEASES + write_lease_file("default", content) + result = @server.send(:ip_address_from_leasefile, @net, @mac) + assert_equal "192.168.122.20", result + end + + def test_mac_matching_is_case_insensitive + write_lease_file("default", "1000 52:54:00:01:02:03 192.168.122.10 host1 *\n") + result = @server.send(:ip_address_from_leasefile, @net, "52:54:00:01:02:03".upcase) + assert_equal "192.168.122.10", result + end + + def test_returns_nil_when_lease_file_missing + result = @server.send(:ip_address_from_leasefile, @net, @mac) + assert_nil result + end + + def test_skips_malformed_lines + content = <<~LEASES + short line + 1000 52:54:00:01:02:03 192.168.122.10 host1 * + incomplete + LEASES + write_lease_file("default", content) + result = @server.send(:ip_address_from_leasefile, @net, @mac) + assert_equal "192.168.122.10", result + end + + def test_returns_nil_when_no_mac_matches + write_lease_file("default", "1000 52:54:00:ff:ff:ff 192.168.122.99 other *\n") + result = @server.send(:ip_address_from_leasefile, @net, @mac) + assert_nil result + end + + def test_returns_nil_on_permission_error + skip("Cannot test permission denial as root") if Process.uid.zero? + write_lease_file("default", "1000 52:54:00:01:02:03 192.168.122.10 host1 *\n") + File.chmod(0o000, File.join(@tmpdir, "default.leases")) + result = @server.send(:ip_address_from_leasefile, @net, @mac) + assert_nil result + end + + def test_returns_nil_when_net_name_is_nil + net_nil_name = stub(:name => nil) + result = @server.send(:ip_address_from_leasefile, net_nil_name, @mac) + assert_nil result + end + + def test_addresses_uses_leasefile_for_local_connection + write_lease_file("default", "1000 52:54:00:01:02:03 192.168.122.10 host1 *\n") + stub_addresses_lookup(:remote => false) + result = @server.send(:addresses) + assert_equal "192.168.122.10", result[:public].first + end + + def test_addresses_skips_leasefile_for_remote_connection + write_lease_file("default", "1000 52:54:00:01:02:03 192.168.122.10 host1 *\n") + stub_addresses_lookup(:remote => true) + result = @server.send(:addresses) + assert_nil result[:public].first + end + + private + + # Wire up the minimum collaborators so #addresses reaches the lease-file + # fallback: a NIC with no libvirt DHCP lease, and a connection whose URI is + # local or remote depending on :remote. + def stub_addresses_lookup(remote:) + nic = stub(:mac => @mac, :network => "default") + @server.stubs(:nics).returns([nic]) + net = stub(:name => "default", :dhcp_leases => []) + networks = stub + networks.stubs(:all).returns([net]) + @server.service.stubs(:networks).returns(networks) + @server.service.stubs(:uri).returns(stub(:remote? => remote)) + end + + def write_lease_file(net_name, content) + File.write(File.join(@tmpdir, "#{net_name}.leases"), content) + end +end