From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id 7BDAE1FF0B3 for ; Wed, 09 Sep 2026 12:42:33 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id B59A32164D; Wed, 09 Sep 2026 12:42:10 +0200 (CEST) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH pve-network v2 04/16] sdn: push mapping changes from the ipam API to the dhcp backend Date: Wed, 9 Sep 2026 12:41:32 +0200 Message-ID: <20260909104144.1110031-5-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260909104144.1110031-1-h.laimer@proxmox.com> References: <20260909104144.1110031-1-h.laimer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1788950505352 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.551 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: GRBQDOQNFIR7ENYVUHHHUXAKUPF4NWQW X-Message-ID-Hash: GRBQDOQNFIR7ENYVUHHHUXAKUPF4NWQW X-MailFrom: h.laimer@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Mapping edits through the API only wrote the IPAM record and the cache, the dhcp backend was never told. So dnsmasq kept serving stale reservations until a later guest start in the zone happened to sweep them. The same held for a guest whose NIC moved to another bridge. The old records were released and new ones allocated without the backend hearing of either. Push the MAC's current answer after every record change on the node the change is made on, the other nodes still catch up at a guest start there. A guest start pushes once after allocating what it lacks, so a guest whose records exist already is pushed as well. The push is best effort, the record write stays authoritative. It goes to the applied zone's backend, a zone edited but not applied has nothing running yet. dnsmasq rewrites the MAC's reservation in one pass and reloads only when the file changed. So an unchanged record costs no reload, and a changed one leaves no moment in which the MAC is unknown. The cache is read under the ethers lock, a snapshot from before it would sweep a line another push wrote meanwhile. So the dispatcher hands over only the MAC. A reservation line is matched to its MAC regardless of case, the cache and the file may spell it differently. Removing a mapping was dispatched to the plugins but implemented nowhere, it is the empty case of that rewrite. A zone confined to other nodes has no backend here and is skipped. A failed lease refresh only warns, the reservation is written by then. Signed-off-by: Hannes Laimer --- src/PVE/API2/Network/SDN/Ips.pm | 7 + src/PVE/Network/SDN/Dhcp.pm | 44 +++--- src/PVE/Network/SDN/Dhcp/Dnsmasq.pm | 101 ++++++++---- src/PVE/Network/SDN/Dhcp/Plugin.pm | 6 +- src/PVE/Network/SDN/Vnets.pm | 5 +- src/test/run_test_vnets_blackbox.pl | 229 +++++++++++++++++++++++++++- 6 files changed, 329 insertions(+), 63 deletions(-) diff --git a/src/PVE/API2/Network/SDN/Ips.pm b/src/PVE/API2/Network/SDN/Ips.pm index 5ff05e7..d7b682d 100644 --- a/src/PVE/API2/Network/SDN/Ips.pm +++ b/src/PVE/API2/Network/SDN/Ips.pm @@ -46,6 +46,8 @@ __PACKAGE__->register_method({ eval { PVE::Network::SDN::Vnets::del_ip($vnet, $ip, '', $mac); }; die "$@\n" if $@; + PVE::Network::SDN::Dhcp::update_mapping($vnet, $mac); + return undef; }, }); @@ -82,6 +84,8 @@ __PACKAGE__->register_method({ PVE::Network::SDN::Vnets::add_ip($vnet, $ip, '', $mac, undef); + PVE::Network::SDN::Dhcp::update_mapping($vnet, $mac); + return undef; }, }); @@ -132,6 +136,9 @@ __PACKAGE__->register_method({ } die "$error\n" if $error; + + PVE::Network::SDN::Dhcp::update_mapping($vnet, $mac); + return undef; }, }); diff --git a/src/PVE/Network/SDN/Dhcp.pm b/src/PVE/Network/SDN/Dhcp.pm index 65e40d4..28a9f2e 100644 --- a/src/PVE/Network/SDN/Dhcp.pm +++ b/src/PVE/Network/SDN/Dhcp.pm @@ -6,7 +6,6 @@ use warnings; use PVE::Cluster; use PVE::Network::SDN; -use PVE::Network::SDN::Ipams; use PVE::Network::SDN::Subnets; use PVE::Network::SDN::Dhcp::Plugin; use PVE::Network::SDN::Dhcp::Dnsmasq; @@ -22,37 +21,28 @@ sub plugin_types { return PVE::Network::SDN::Dhcp::Plugin->lookup_types(); } -sub add_mapping { - my ($vnetid, $mac, $ip4, $ip6) = @_; - - my $vnet = PVE::Network::SDN::Vnets::get_vnet($vnetid); - return if !$vnet; - - my $zoneid = $vnet->{zone}; - my $zone = PVE::Network::SDN::Zones::get_zone($zoneid); - - return if !$zone->{ipam} || !$zone->{dhcp}; - - my $dhcptype = $zone->{dhcp}; - - my $macdb = PVE::Network::SDN::Ipams::read_macdb(); - my $dhcp_plugin = PVE::Network::SDN::Dhcp::Plugin->lookup($dhcptype); - $dhcp_plugin->add_ip_mapping($zoneid, $macdb, $mac, $ip4, $ip6); -} - -sub remove_mapping { +# re-apply a MAC's mapping on this node from the current records, best +# effort, the record write stays authoritative +sub update_mapping { my ($vnetid, $mac) = @_; - my $vnet = PVE::Network::SDN::Vnets::get_vnet($vnetid); - return if !$vnet; + eval { + # the backend to push to is the applied one, a zone edited but not + # applied has nothing running yet + my $vnet = PVE::Network::SDN::Vnets::get_vnet($vnetid, 1); + return if !$vnet; - my $zoneid = $vnet->{zone}; - my $zone = PVE::Network::SDN::Zones::get_zone($zoneid); + my $zoneid = $vnet->{zone}; + my $zone = PVE::Network::SDN::Zones::get_zone($zoneid, 1); + return if !$zone || !$zone->{ipam} || !$zone->{dhcp}; - return if !$zone->{ipam} || !$zone->{dhcp}; + # a zone confined to other nodes has no backend running here + return if defined($zone->{nodes}) && !$zone->{nodes}->{ PVE::INotify::nodename() }; - my $dhcp_plugin = PVE::Network::SDN::Dhcp::Plugin->lookup($zone->{dhcp}); - $dhcp_plugin->del_ip_mapping($zoneid, $mac); + PVE::Network::SDN::Dhcp::Plugin->lookup($zone->{dhcp}) + ->update_ip_mapping($zoneid, $mac); + }; + warn "could not update dhcp mapping for $mac: $@" if $@; } sub regenerate_config { diff --git a/src/PVE/Network/SDN/Dhcp/Dnsmasq.pm b/src/PVE/Network/SDN/Dhcp/Dnsmasq.pm index 477b700..f3c7739 100644 --- a/src/PVE/Network/SDN/Dhcp/Dnsmasq.pm +++ b/src/PVE/Network/SDN/Dhcp/Dnsmasq.pm @@ -12,6 +12,7 @@ use File::Copy; use Net::DBus; use PVE::RESTEnvironment qw(log_warn); +use PVE::Network::SDN::Ipams; my $DNSMASQ_CONFIG_ROOT = '/etc/dnsmasq.d'; my $DNSMASQ_DEFAULT_ROOT = '/etc/default'; @@ -51,29 +52,65 @@ sub update_lease { $manager->AddDhcpLease($ip4, $mac, \@hostname, undef, 0, 0, 0) if $ip4; } -sub add_ip_mapping { - my ($class, $dhcpid, $macdb, $mac, $ip4, $ip6) = @_; +sub update_ip_mapping { + my ($class, $dhcpid, $mac) = @_; my $ethers_file = ethers_file($dhcpid); my $ethers_tmp_file = "$ethers_file.tmp"; - my $reload = undef; + my ($ip4, $ip6); + my $reservation = undef; + my $changed = undef; + + my $rewriteFn = sub { + # read under the ethers lock. A snapshot from before it would sweep a + # line another push wrote meanwhile, and an older push would put its + # addresses back + + my $macdb = PVE::Network::SDN::Ipams::read_macdb(); + # the cache spells a MAC and an address as their writer did, the file + # holds one spelling of both. An entry without an address says nothing + # about its MAC, a line of it is stale + my %macs; + for my $key (sort keys $macdb->{macs}->%*) { + my $entry = $macdb->{macs}->{$key}; + my %addresses = + map { $_ => lc($entry->{$_}) } grep { defined($entry->{$_}) } qw(ip4 ip6); + next if !%addresses; + $macs{ lc($key) } = { %{ $macs{ lc($key) } // {} }, %addresses }; + } + + my $entry = $macs{ lc($mac) }; + ($ip4, $ip6) = ($entry->{ip4}, $entry->{ip6}) if $entry; + if ($ip4 || $ip6) { + $reservation = $mac; + $reservation .= ",$ip4" if $ip4; + $reservation .= ",[$ip6]" if $ip6; + # an external IPAM spells addresses its own way, the file holds one spelling + $reservation = lc($reservation); + } - my $appendFn = sub { + # the first reservation of a zone finds no file yet + file_set_contents($ethers_file, '') if !-e $ethers_file; open(my $in, '<', $ethers_file) or die "Could not open file '$ethers_file' $!\n"; open(my $out, '>', $ethers_tmp_file) or die "Could not open file '$ethers_tmp_file' $!\n"; - my $match = undef; + my $kept = undef; - my $line_no = 0; while (my $line = <$in>) { - $line_no++; chomp($line); + next if $line !~ m/\S/; my ($parsed_mac, $parsed_ip1, $parsed_ip2) = split(/,/, $line); - if (!defined($parsed_mac)) { - warn "failed to parse MAC from $dhcpid ethers file on line $line_no: '$line'\n"; + # the MAC's own reservation is rewritten, an unchanged one stays in place + if (lc($parsed_mac) eq lc($mac)) { + if (defined($reservation) && !$kept && lc($line) eq $reservation) { + $kept = 1; + print $out "$line\n"; + } else { + $changed = 1; + } next; } @@ -81,41 +118,35 @@ sub add_ip_mapping { if ($parsed_ip2) { $parsed_ip4 = $parsed_ip1; $parsed_ip6 = $parsed_ip2; - } elsif (Net::IP::ip_is_ipv4($parsed_ip1)) { + } elsif (defined($parsed_ip1) && Net::IP::ip_is_ipv4($parsed_ip1)) { $parsed_ip4 = $parsed_ip1; - } else { + } elsif (defined($parsed_ip1)) { $parsed_ip6 = $parsed_ip1; } $parsed_ip6 = $1 if $parsed_ip6 && $parsed_ip6 =~ m/\[(\S+)\]/; + # a line of the old code may spell an address in upper case + $parsed_ip6 = lc($parsed_ip6) if $parsed_ip6; #delete changed if ( - !defined($macdb->{macs}->{$parsed_mac}) + !defined($macs{ lc($parsed_mac) }) || ($parsed_ip4 - && $macdb->{macs}->{$parsed_mac}->{'ip4'} - && $macdb->{macs}->{$parsed_mac}->{'ip4'} ne $parsed_ip4) + && $macs{ lc($parsed_mac) }->{'ip4'} + && $macs{ lc($parsed_mac) }->{'ip4'} ne $parsed_ip4) || ($parsed_ip6 - && $macdb->{macs}->{$parsed_mac}->{'ip6'} - && $macdb->{macs}->{$parsed_mac}->{'ip6'} ne $parsed_ip6) + && $macs{ lc($parsed_mac) }->{'ip6'} + && $macs{ lc($parsed_mac) }->{'ip6'} ne $parsed_ip6) ) { - $reload = 1; + $changed = 1; next; } - if ($parsed_mac eq $mac) { - $match = 1 if $ip4 && $parsed_ip4 && $ip4; - $match = 1 if $ip6 && $parsed_ip6 && $ip6; - } - print $out "$line\n"; } - if (!$match) { - my $reservation = $mac; - $reservation .= ",$ip4" if $ip4; - $reservation .= ",[$ip6]" if $ip6; + if (defined($reservation) && !$kept) { print $out "$reservation\n"; - $reload = 1; + $changed = 1; } close $in; @@ -124,16 +155,22 @@ sub add_ip_mapping { chmod 0644, $ethers_file; }; - PVE::Tools::lock_file($ethers_file, 10, $appendFn); + # the rewrite replaces the reservation file, a lock on the file itself + # would not outlive the rename + PVE::Tools::lock_file("$ethers_file.lock", 10, $rewriteFn); if ($@) { - warn "Unable to add $mac to the dnsmasq configuration: $@\n"; + warn "Unable to update $mac in the dnsmasq configuration: $@\n"; return; } - my $service_name = "dnsmasq\@$dhcpid"; - systemctl_service('reload', $service_name) if $reload; - update_lease($dhcpid, $ip4, $mac); + systemctl_service('reload', "dnsmasq\@$dhcpid") if $changed; + + # the reservation is written, a lease refresh that fails only warns + if ($ip4) { + eval { update_lease($dhcpid, $ip4, $mac) }; + log_warn("could not update the dnsmasq lease of $mac: $@") if $@; + } } sub configure_subnet { diff --git a/src/PVE/Network/SDN/Dhcp/Plugin.pm b/src/PVE/Network/SDN/Dhcp/Plugin.pm index b5d32fa..3b95a68 100644 --- a/src/PVE/Network/SDN/Dhcp/Plugin.pm +++ b/src/PVE/Network/SDN/Dhcp/Plugin.pm @@ -22,8 +22,10 @@ sub private { return $defaultData; } -sub add_ip_mapping { - my ($class, $dhcpid, $macdb, $mac, $ip4, $ip6) = @_; +# the MAC's records changed, make the backend serve the current ones, none +# meaning its reservation goes +sub update_ip_mapping { + my ($class, $dhcpid, $mac) = @_; die 'implement in sub class'; } diff --git a/src/PVE/Network/SDN/Vnets.pm b/src/PVE/Network/SDN/Vnets.pm index c327a4b..8453a27 100644 --- a/src/PVE/Network/SDN/Vnets.pm +++ b/src/PVE/Network/SDN/Vnets.pm @@ -211,6 +211,8 @@ sub del_ips_from_mac { PVE::Network::SDN::Vnets::del_ip($vnetid, $ip4, $hostname, $mac) if $ip4; PVE::Network::SDN::Vnets::del_ip($vnetid, $ip6, $hostname, $mac) if $ip6; + PVE::Network::SDN::Dhcp::update_mapping($vnetid, $mac) if $ip4 || $ip6; + return ($ip4, $ip6); } @@ -225,11 +227,12 @@ sub add_dhcp_mapping { return if !$zone->{ipam} || !$zone->{dhcp}; my ($ip4, $ip6) = PVE::Network::SDN::Vnets::get_ips_from_mac($vnetid, $mac); + # one push at the end covers the allocated and the existing records alike add_next_free_cidr($vnetid, $name, $mac, "$vmid", undef, 1, 4) if !$ip4; add_next_free_cidr($vnetid, $name, $mac, "$vmid", undef, 1, 6) if !$ip6; ($ip4, $ip6) = PVE::Network::SDN::Vnets::get_ips_from_mac($vnetid, $mac); - PVE::Network::SDN::Dhcp::add_mapping($vnetid, $mac, $ip4, $ip6) if $ip4 || $ip6; + PVE::Network::SDN::Dhcp::update_mapping($vnetid, $mac) if $ip4 || $ip6; } 1; diff --git a/src/test/run_test_vnets_blackbox.pl b/src/test/run_test_vnets_blackbox.pl index 9f4c424..8273715 100755 --- a/src/test/run_test_vnets_blackbox.pl +++ b/src/test/run_test_vnets_blackbox.pl @@ -46,6 +46,7 @@ sub clear_test_state { vnets_config => {}, macdb => {}, ipamdb => {}, + dnsmasq_calls => [], ipam_config => { 'ids' => { 'pve' => { @@ -220,7 +221,9 @@ $mocked_sdn_dhcp_dnsmasq->mock( assert_dnsmasq_installed => sub { return 1; }, before_configure => sub { }, ethers_file => sub { return "/tmp/ethers"; }, - systemctl_service => sub { }, + systemctl_service => sub { + push $test_state->{dnsmasq_calls}->@*, $_[0]; + }, update_lease => sub { }, ); @@ -239,6 +242,11 @@ $mocked_rpc_env_obj->mock( check_any => sub { return 1; }, ); +my $mocked_pve_inotify = Test::MockModule->new('PVE::INotify'); +$mocked_pve_inotify->mock( + nodename => sub { return 'localnode'; }, +); + my $mocked_pve_cluster_obj = Test::MockModule->new('PVE::Cluster'); $mocked_pve_cluster_obj->mock( check_cfs_quorum => sub { return 1; }, @@ -288,6 +296,11 @@ sub create_zone { return $zone; } +sub update_zone { + my ($zoneid, $params) = @_; + PVE::API2::Network::SDN::Zones->update({ zone => $zoneid, %$params }); +} + sub get_vnet { my ($id) = @_; return eval { PVE::API2::Network::SDN::Vnets->read({ vnet => $id }); }; @@ -330,6 +343,16 @@ sub create_ip { return PVE::API2::Network::SDN::Ips->ipcreate($param); } +sub update_ip { + my ($param) = @_; + return PVE::API2::Network::SDN::Ips->ipupdate($param); +} + +sub delete_ip { + my ($param) = @_; + return PVE::API2::Network::SDN::Ips->ipdelete($param); +} + sub run_test { my $test = shift; clear_test_state(); @@ -963,4 +986,208 @@ run_test( 2, ); +# -------------- dnsmasq mapping pushes + +sub test_dnsmasq_mapping_push { + my $test_name = (split(/::/, (caller(0))[3]))[-1]; + my $zoneid = "TESTZONE"; + my $vnetid = "testvnet"; + my $mac = "da:65:8f:18:9b:6f"; + + create_zone({ + type => "simple", + dhcp => "dnsmasq", + ipam => "pve", + zone => $zoneid, + }); + create_vnet({ + type => "vnet", + zone => $zoneid, + vnet => $vnetid, + }); + create_subnet({ + type => "subnet", + vnet => $vnetid, + subnet => "10.0.0.0/24", + gateway => "10.0.0.1", + 'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"], + }); + + # the first push of a zone finds no ethers file yet + unlink($TMP_ETHERS_FILE); + + my $ethers = sub { PVE::Tools::file_get_contents($TMP_ETHERS_FILE) }; + my $reloads = sub { + my $count = scalar(grep { $_ eq 'reload' } $test_state->{dnsmasq_calls}->@*); + $test_state->{dnsmasq_calls} = []; + return $count; + }; + + eval { nic_start($vnetid, $mac, "999", "testhostname"); }; + if ($@) { + fail("$test_name: nic_start: $@"); + return; + } + is($ethers->(), "$mac,10.0.0.100\n", "$test_name: guest start reserves the allocation"); + is($reloads->(), 1, "$test_name: guest start reloads once"); + + # the records exist already, the restart pushes them without a change + nic_start($vnetid, $mac, "999", "testhostname"); + is($ethers->(), "$mac,10.0.0.100\n", "$test_name: restart keeps the reservation"); + is($reloads->(), 0, "$test_name: restart without a change does not reload"); + + update_ip({ + zone => $zoneid, + vnet => $vnetid, + mac => $mac, + ip => "10.0.0.150", + }); + is($ethers->(), "$mac,10.0.0.150\n", "$test_name: mapping edit replaces the reservation"); + is($reloads->(), 1, "$test_name: mapping edit reloads once"); + + # a push writes what the cache holds, so a repeated one rewrites nothing + PVE::Network::SDN::Dhcp::Dnsmasq->update_ip_mapping($zoneid, $mac); + is($ethers->(), "$mac,10.0.0.150\n", "$test_name: a repeated push keeps the reservation"); + is($reloads->(), 0, "$test_name: a repeated push changes nothing"); + + # the cache and the file may spell a MAC differently, it is one MAC + PVE::Network::SDN::Dhcp::Dnsmasq->update_ip_mapping($zoneid, uc($mac)); + is( + $ethers->(), + "$mac,10.0.0.150\n", + "$test_name: a push spelled differently keeps the line as it is", + ); + is($reloads->(), 0, "$test_name: and reloads nothing"); + + delete_ip({ + zone => $zoneid, + vnet => $vnetid, + mac => $mac, + ip => "10.0.0.150", + }); + is($ethers->(), "", "$test_name: mapping delete drops the reservation"); + is($reloads->(), 1, "$test_name: mapping delete reloads once"); + + # a zone confined to other nodes runs no dnsmasq here, an edit is not + # pushed on this node + update_zone($zoneid, { nodes => 'other' }); + create_ip({ + zone => $zoneid, + vnet => $vnetid, + mac => $mac, + ip => "10.0.0.150", + }); + is($ethers->(), "", "$test_name: a zone confined to other nodes gets no push here"); + is($reloads->(), 0, "$test_name: nothing to reload for it"); +} + +run_test(\&test_dnsmasq_mapping_push); + +sub test_dnsmasq_dual_stack_and_sweep { + my $test_name = (split(/::/, (caller(0))[3]))[-1]; + my $zoneid = "TESTZONE"; + my $vnetid = "testvnet"; + my $mac = "da:65:8f:18:9b:6f"; + + create_zone({ + type => "simple", + dhcp => "dnsmasq", + ipam => "pve", + zone => $zoneid, + }); + create_vnet({ + type => "vnet", + zone => $zoneid, + vnet => $vnetid, + }); + create_subnet({ + type => "subnet", + vnet => $vnetid, + subnet => "10.0.0.0/24", + gateway => "10.0.0.1", + 'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"], + }); + create_subnet({ + type => "subnet", + vnet => $vnetid, + subnet => "8888::/64", + gateway => "8888::1", + 'dhcp-range' => ["start-address=8888::100,end-address=8888::200"], + }); + + # a reservation of a MAC the cache does not know is swept by the next + # rewrite, both addresses of the guest go on one line + PVE::Tools::file_set_contents($TMP_ETHERS_FILE, "aa:bb:cc:dd:ee:ff,10.0.0.77\n"); + $test_state->{dnsmasq_calls} = []; + + eval { nic_start($vnetid, $mac, "999", "testhostname"); }; + if ($@) { + fail("$test_name: nic_start: $@"); + return; + } + is( + PVE::Tools::file_get_contents($TMP_ETHERS_FILE), + "$mac,10.0.0.100,[8888::100]\n", + "$test_name: dual-stack reservation on one line, the unknown MAC swept", + ); + is( + scalar(grep { $_ eq 'reload' } $test_state->{dnsmasq_calls}->@*), + 1, + "$test_name: one reload for the rewrite", + ); + + # an external IPAM may spell an address in upper case, the file holds + # one spelling + $test_state->{macdb}->{macs}->{$mac}->{ip6} = '8888::AB'; + $test_state->{dnsmasq_calls} = []; + PVE::Network::SDN::Dhcp::Dnsmasq->update_ip_mapping($zoneid, $mac); + is( + PVE::Tools::file_get_contents($TMP_ETHERS_FILE), + "$mac,10.0.0.100,[8888::ab]\n", + "$test_name: the reservation is written in one spelling", + ); + is( + scalar(grep { $_ eq 'reload' } $test_state->{dnsmasq_calls}->@*), + 1, + "$test_name: one reload for the changed address", + ); + + # an entry without an address, as the old code wrote on a miss, says + # nothing about its MAC, so a line of it is swept + my $stale = 'da:65:8f:18:9b:70'; + $test_state->{macdb}->{macs}->{$stale} = { ip4 => undef, ip6 => undef }; + PVE::Tools::file_set_contents( + $TMP_ETHERS_FILE, + "$mac,10.0.0.100,[8888::ab]\n$stale,10.0.0.101\n", + ); + PVE::Network::SDN::Dhcp::Dnsmasq->update_ip_mapping($zoneid, $mac); + is( + PVE::Tools::file_get_contents($TMP_ETHERS_FILE), + "$mac,10.0.0.100,[8888::ab]\n", + "$test_name: a line of a MAC cached without an address is swept", + ); + + # a line the old code wrote with an upper-case IPv6 address is kept + my $other = 'da:65:8f:18:9b:71'; + $test_state->{macdb}->{macs}->{$other} = { ip4 => '10.0.0.102', ip6 => '8888::AB' }; + PVE::Tools::file_set_contents( + $TMP_ETHERS_FILE, + "$mac,10.0.0.100,[8888::ab]\n$other,10.0.0.102,[8888::AB]\n", + ); + $test_state->{dnsmasq_calls} = []; + PVE::Network::SDN::Dhcp::Dnsmasq->update_ip_mapping($zoneid, $mac); + is( + PVE::Tools::file_get_contents($TMP_ETHERS_FILE), + "$mac,10.0.0.100,[8888::ab]\n$other,10.0.0.102,[8888::AB]\n", + "$test_name: a line of another MAC spelled by the old code is kept", + ); + is( + scalar(grep { $_ eq 'reload' } $test_state->{dnsmasq_calls}->@*), + 0, + "$test_name: without a reload", + ); +} + +run_test(\&test_dnsmasq_dual_stack_and_sweep); + done_testing(); -- 2.47.3