public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Hannes Laimer <h.laimer@proxmox.com>
To: Stefan Hanreich <s.hanreich@proxmox.com>, pve-devel@lists.proxmox.com
Subject: Re: [PATCH pve-network v3 05/17] sdn: dhcp: update reservations when ip mappings change
Date: Wed, 21 Oct 2026 09:21:04 +0200	[thread overview]
Message-ID: <b7ac5b67-4428-4b60-bf91-7b30047ed3db@proxmox.com> (raw)
In-Reply-To: <b5a19351-b64f-4569-904e-2f3191dc2a4e@proxmox.com>

On 2026-10-20 15:42, Stefan Hanreich wrote:
> Tested-by: Stefan Hanreich <s.hanreich@proxmox.com>
> 
> On 10/20/26 10:03 AM, Hannes Laimer wrote:
>> Creating, updating or deleting an IP mapping through the API changed
>> the IPAM and the MAC cache, but never the zone's dnsmasq reservation
>> file. dnsmasq kept serving the old reservation, or none for a new
>> mapping, until the guest's next start. Deleting a guest NIC's
>> addresses left its reservation in the file as well.
>>
>> A guest start, an API edit and the deletion of a NIC's addresses now
>> all rewrite the MAC's reservation from the cache, on the node the
>> change is made on. The other nodes catch up when the guest starts
>> there. The zone and its backend come from the applied config, and a
>> zone confined to other nodes is skipped. A failed update only warns,
>> since the IPAM records are already written.
>>
>> The rewrite reads the cache only once it holds the file's lock. A
>> read from before could undo a rewrite that ran in between. The lock
>> is on a separate file, since a lock on the reservation file would not
>> survive the rename that replaces it. The MAC's line is written in full
>> from the cache, so a guest gaining an IPv6 address next to its IPv4
>> one gets it reserved too. MACs and addresses are compared in lower
>> case, since the cache keeps each as its writer spelled it. A cache
>> entry without any address no longer keeps a stale line.
>>
>> Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
>> ---
>>  src/PVE/API2/Network/SDN/Ips.pm     |   7 +
>>  src/PVE/Network/SDN/Dhcp.pm         |  48 +++---
>>  src/PVE/Network/SDN/Dhcp/Dnsmasq.pm | 110 ++++++++----
>>  src/PVE/Network/SDN/Dhcp/Plugin.pm  |   7 +-
>>  src/PVE/Network/SDN/Vnets.pm        |   5 +-
>>  src/test/run_test_vnets_blackbox.pl | 250 +++++++++++++++++++++++++++-
>>  6 files changed, 352 insertions(+), 75 deletions(-)
>>
>> diff --git a/src/PVE/API2/Network/SDN/Ips.pm b/src/PVE/API2/Network/SDN/Ips.pm
>> index 5ff05e75..d7b682dd 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 65e40d40..ab5d6b5b 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,32 @@ sub plugin_types {
>>      return PVE::Network::SDN::Dhcp::Plugin->lookup_types();
>>  }
>>  
>> -sub add_mapping {
>> -    my ($vnetid, $mac, $ip4, $ip6) = @_;
>> +# re-apply a MAC's mapping on this node from the current records, best effort
>> +# since the record write stays authoritative. A caller that knows the guest
>> +# passes its vmid, so only that guest's config is read
>> +sub update_mapping {
>> +    my ($vnetid, $mac, $vmid) = @_;
>>  
>> -    my $vnet = PVE::Network::SDN::Vnets::get_vnet($vnetid);
>> -    return if !$vnet;
>> +    # a record without a MAC has no guest NIC behind it, so no backend serves it
>> +    return if !$mac;
>>  
>> -    my $zoneid = $vnet->{zone};
>> -    my $zone = PVE::Network::SDN::Zones::get_zone($zoneid);
>> +    eval {
>> +        # the backend to push to is the applied one, since a zone edited but
>> +        # not applied has nothing running yet
>> +        my $vnet = PVE::Network::SDN::Vnets::get_vnet($vnetid, 1);
>> +        return if !$vnet;
>>  
>> -    return if !$zone->{ipam} || !$zone->{dhcp};
>> +        my $zoneid = $vnet->{zone};
>> +        my $zone = PVE::Network::SDN::Zones::get_zone($zoneid, 1);
>> +        return if !$zone || !$zone->{ipam} || !$zone->{dhcp};
>>  
>> -    my $dhcptype = $zone->{dhcp};
>> +        # a zone confined to other nodes has no backend running here
>> +        return if defined($zone->{nodes}) && !$zone->{nodes}->{ PVE::INotify::nodename() };
>>  
>> -    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 {
>> -    my ($vnetid, $mac) = @_;
>> -
>> -    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 $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, $vmid);
>> +    };
>> +    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 5c9496c0..57ce1f73 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, while the
>> +        # file holds one spelling of both. An entry without an address says
>> +        # nothing about its MAC, so 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 };
>> +        }
> 
> we could simplify this here as well with integrating case-folding into the read_macdb fn.
> The more I think about, the more I'd prefer a small object that provides an API similar
> to HashMaps that handle case-folding when querying / inserting / ... I think that'd save
> us lots of duplication.
> 

like on 06/17 yes, not sure about the hash though, having the existing
functions do it should do the same i think

>> +
>> +        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, while 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,50 +118,43 @@ 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 from before addresses were lower-cased may spell one in upper case
>> +            $parsed_ip6 = lc($parsed_ip6) if $parsed_ip6;
>>  
>>              # a line that no longer matches the cache is replaced by the cache's
>>              # current one. Dropping it left that guest unknown to dnsmasq until
>>              # its next start
>>              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;
>> -                # the MAC being added gets its line below, like a missing one
>> -                my $current = $macdb->{macs}->{$parsed_mac};
>> -                next if $parsed_mac eq $mac || !$current;
>> -                my $replacement = $parsed_mac;
>> +                $changed = 1;
>> +                my $current = $macs{ lc($parsed_mac) };
>> +                next if !$current;
>> +                my $replacement = lc($parsed_mac);
> 
> a dedicated class could simplify this here a lot
> 
>>                  $replacement .= ",$current->{ip4}" if $current->{ip4};
>>                  $replacement .= ",[$current->{ip6}]" if $current->{ip6};
>> -                print $out "$replacement\n" if $replacement ne $parsed_mac;
>> +                print $out "$replacement\n";
>>                  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;
>> @@ -133,16 +163,22 @@ sub add_ip_mapping {
>>          chmod 0644, $ethers_file;
>>      };
>>  
>> -    PVE::Tools::lock_file($ethers_file, 10, $appendFn);
>> +    # the rewrite replaces the reservation file, so 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, so 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 b5d32fa1..2fa8683c 100644
>> --- a/src/PVE/Network/SDN/Dhcp/Plugin.pm
>> +++ b/src/PVE/Network/SDN/Dhcp/Plugin.pm
>> @@ -22,8 +22,11 @@ sub private {
>>      return $defaultData;
>>  }
>>  
>> -sub add_ip_mapping {
>> -    my ($class, $dhcpid, $macdb, $mac, $ip4, $ip6) = @_;
>> +# the MAC's records changed, so make the backend serve the current ones. With no
>> +# record left its reservation goes. The vmid names the guest when the caller
>> +# knows it, which an API edit does not
>> +sub update_ip_mapping {
>> +    my ($class, $dhcpid, $mac, $vmid) = @_;
>>      die 'implement in sub class';
>>  }
>>  
>> diff --git a/src/PVE/Network/SDN/Vnets.pm b/src/PVE/Network/SDN/Vnets.pm
>> index c327a4be..84b08037 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, $vmid) if $ip4 || $ip6;
>>  }
>>  
>>  1;
>> diff --git a/src/test/run_test_vnets_blackbox.pl b/src/test/run_test_vnets_blackbox.pl
>> index 36225a92..d324538e 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();
>> @@ -977,18 +1000,229 @@ sub test_dnsmasq_stale_reservation_replaced {
>>      };
>>      PVE::Tools::file_set_contents($TMP_ETHERS_FILE, "$stale,10.0.1.10\n$gone,10.0.1.12\n");
>>  
>> -    PVE::Network::SDN::Dhcp::Dnsmasq->add_ip_mapping(
>> -        'TESTZONE', $test_state->{macdb}, $added, '10.0.1.13', undef,
>> -    );
>> +    PVE::Network::SDN::Dhcp::Dnsmasq->update_ip_mapping('TESTZONE', $added);
>>  
>> -    my @lines = sort grep { /\S/ } split(/\n/, PVE::Tools::file_get_contents($TMP_ETHERS_FILE));
>> -    eq_or_diff(
>> -        \@lines,
>> -        ["$stale,10.0.1.11", "$added,10.0.1.13"],
>> +    is(
>> +        PVE::Tools::file_get_contents($TMP_ETHERS_FILE),
>> +        "$stale,10.0.1.11\n$added,10.0.1.13\n",
>>          "$test_name: the stale line is replaced, the unknown one dropped",
>>      );
>>  }
>>  
>>  run_test(\&test_dnsmasq_stale_reservation_replaced);
>>  
>> +# -------------- 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, so 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, but 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 record without a MAC has no NIC to serve, so nothing is pushed for it
>> +    my @warnings;
>> +    {
>> +        local $SIG{__WARN__} = sub { push @warnings, @_; };
>> +        create_ip({ zone => $zoneid, vnet => $vnetid, ip => "10.0.0.160" });
>> +        delete_ip({ zone => $zoneid, vnet => $vnetid, ip => "10.0.0.160" });
>> +    }
>> +    is($reloads->(), 0, "$test_name: a record without a MAC is not pushed");
>> +    eq_or_diff(\@warnings, [], "$test_name: and warns nothing");
>> +
>> +    # a zone confined to other nodes runs no dnsmasq here, so 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, and 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, but 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 a miss once left behind, 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 with an upper-case IPv6 address from before they were lower-cased 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();
> 
> 
> 
> 
> 





  reply	other threads:[~2026-10-21  7:21 UTC|newest]

Thread overview: 43+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-20  7:59 [PATCH container/docs/manager/network/proxmox{-ebpf,-perl-rs}/qemu-server v3 00/17] sdn: implement DHCP for all zones using eBPF Hannes Laimer
2026-10-20  7:59 ` [PATCH proxmox-ebpf v3 01/17] dhcp: add the per-tap DHCPv4 responder program Hannes Laimer
2026-10-20  7:59 ` [PATCH proxmox-ebpf v3 02/17] dhcp: add the subsystem that loads the responder and keeps its records Hannes Laimer
2026-10-20 13:09   ` Stefan Hanreich
2026-10-21  7:09     ` Hannes Laimer
2026-10-20  7:59 ` [PATCH proxmox-perl-rs v3 03/17] pve-rs: sdn: add bindings for the eBPF DHCP responder Hannes Laimer
2026-10-20 13:10   ` Stefan Hanreich
2026-10-21  7:32     ` Hannes Laimer
2026-10-20  7:59 ` [PATCH pve-network v3 04/17] sdn: dhcp: replace a stale dnsmasq reservation instead of dropping it Hannes Laimer
2026-10-20 13:15   ` Stefan Hanreich
2026-10-20  7:59 ` [PATCH pve-network v3 05/17] sdn: dhcp: update reservations when ip mappings change Hannes Laimer
2026-10-20 13:42   ` Stefan Hanreich
2026-10-21  7:21     ` Hannes Laimer [this message]
2026-10-20  7:59 ` [PATCH pve-network v3 06/17] sdn: ipam: key the MAC cache by lower-case MAC, stop caching misses Hannes Laimer
2026-10-20 13:10   ` Stefan Hanreich
2026-10-21  6:17     ` Hannes Laimer
2026-10-20  7:59 ` [PATCH pve-network v3 07/17] sdn: dhcp: make the lease time configurable per subnet Hannes Laimer
2026-10-20 13:17   ` Stefan Hanreich
2026-10-20  7:59 ` [PATCH pve-network v3 08/17] sdn: dhcp: require a backend only if a zone on this node uses it Hannes Laimer
2026-10-20 13:17   ` Stefan Hanreich
2026-10-20  7:59 ` [PATCH pve-network v3 09/17] sdn: dhcp: add an eBPF backend answering on each guest interface Hannes Laimer
2026-10-20 13:10   ` Stefan Hanreich
2026-10-21  6:16     ` Hannes Laimer
2026-10-20  7:59 ` [PATCH pve-network v3 10/17] sdn: zones: report interface plugs and unplugs to the DHCP backends Hannes Laimer
2026-10-20 13:17   ` Stefan Hanreich
2026-10-20  7:59 ` [PATCH pve-network v3 11/17] sdn: dhcp: push IP mapping edits to the guest's node Hannes Laimer
2026-10-20 13:09   ` Stefan Hanreich
2026-10-21  6:44     ` Hannes Laimer
2026-10-20  8:00 ` [PATCH pve-network v3 12/17] sdn: zones: allow the ebpf DHCP backend on every zone type Hannes Laimer
2026-10-20  8:00 ` [PATCH qemu-server v3 13/17] network: sdn: keep the DHCP backends current on guest NICs Hannes Laimer
2026-10-20 13:38   ` Stefan Hanreich
2026-10-20  8:00 ` [PATCH pve-container v3 14/17] net: report NIC plugs and removals to the SDN DHCP backends Hannes Laimer
2026-10-20 13:36   ` Stefan Hanreich
2026-10-21  7:29     ` Hannes Laimer
2026-10-20  8:00 ` [PATCH pve-manager v3 15/17] ui: sdn: add zone DHCP backend selector and subnet DHCP options Hannes Laimer
2026-10-20 13:19   ` Stefan Hanreich
2026-10-20  8:00 ` [PATCH pve-manager v3 16/17] sdn: bring up the DHCP backends at boot before the guests start Hannes Laimer
2026-10-20 13:21   ` Stefan Hanreich
2026-10-21  7:17     ` Hannes Laimer
2026-10-20  8:00 ` [PATCH pve-docs v3 17/17] sdn: dhcp: add eBPF backend section Hannes Laimer
2026-10-20 13:11 ` [PATCH container/docs/manager/network/proxmox{-ebpf,-perl-rs}/qemu-server v3 00/17] sdn: implement DHCP for all zones using eBPF Stefan Hanreich
2026-10-21  7:35   ` Hannes Laimer
2026-10-21  8:46 ` superseded: " Hannes Laimer

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=b7ac5b67-4428-4b60-bf91-7b30047ed3db@proxmox.com \
    --to=h.laimer@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    --cc=s.hanreich@proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal