public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH pve-network 09/10] sdn: ipam: give each zone a VRF in NetBox and a section in phpIPAM
Date: Mon,  2 Nov 2026 12:06:03 +0100	[thread overview]
Message-ID: <20261102110604.915693-10-h.laimer@proxmox.com> (raw)
In-Reply-To: <20261102110604.915693-1-h.laimer@proxmox.com>

Two zones can use the same subnet, and an external IPAM asked by the
address alone cannot tell their records apart. A zone's subnets now
go into a VRF in NetBox and into a section under the configured
section in phpIPAM, both named pve-<zone> and created with the first
subnet added to the zone. A subnet being added goes into the zone's
VRF or section even when another zone holds the same range elsewhere.
phpIPAM nests sections one level deep, so under a configured section
that is a sub-section itself the zones share the configured section
as before.

Subnets from before stay in the global table or the configured
section and are still found there, so nothing has to be moved. The
phpIPAM lookup by MAC, unimplemented until now, returns the addresses
recorded for the MAC in the vnet's subnets, and its gateway check
answers for the subnet it is asked about. A failed section lookup
stops the operation, only a missing section means the zone has none.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/Network/SDN/Ipams/NetboxPlugin.pm     | 157 +++++++++----
 src/PVE/Network/SDN/Ipams/PhpIpamPlugin.pm    | 163 ++++++++++---
 src/test/ipams/netbox/expected.add_ip         |   2 +-
 .../ipams/netbox/expected.add_ip_notgateway   |   2 +-
 src/test/ipams/netbox/expected.add_subnet     |   2 +-
 src/test/ipams/netbox/expected.update_ip      |   2 +-
 src/test/run_test_ipams.pl                    | 221 ++++++++++++++++++
 7 files changed, 478 insertions(+), 71 deletions(-)

diff --git a/src/PVE/Network/SDN/Ipams/NetboxPlugin.pm b/src/PVE/Network/SDN/Ipams/NetboxPlugin.pm
index 82c9e80b6c6e..ee48a19d5358 100644
--- a/src/PVE/Network/SDN/Ipams/NetboxPlugin.pm
+++ b/src/PVE/Network/SDN/Ipams/NetboxPlugin.pm
@@ -40,18 +40,15 @@ sub netbox_api_request {
 }
 
 sub add_dhcp_range {
-    my ($config, $dhcp_range, $noerr) = @_;
+    my ($config, $dhcp_range, $vrf_id, $noerr) = @_;
 
     my $result = eval {
-        netbox_api_request(
-            $config,
-            "POST",
-            "/ipam/ip-ranges/",
-            {
-                start_address => $dhcp_range->{'start-address'},
-                end_address => $dhcp_range->{'end-address'},
-            },
-        );
+        my $params = {
+            start_address => $dhcp_range->{'start-address'},
+            end_address => $dhcp_range->{'end-address'},
+        };
+        $params->{vrf} = $vrf_id if defined($vrf_id);
+        netbox_api_request($config, "POST", "/ipam/ip-ranges/", $params);
     };
     if ($@) {
         return if $noerr;
@@ -78,12 +75,25 @@ sub add_subnet {
     my $cidr = $subnet->{cidr};
     my $gateway = $subnet->{gateway};
 
-    if (get_prefix_id($plugin_config, $cidr, $noerr)) {
+    # a subnet being added is new to its zone, so only the zone's VRF is
+    # searched, not the global table the prefixes from before sit in
+    my $vrf_id = get_zone_namespace($plugin_config, $subnet->{zone}, $noerr);
+    if (defined($vrf_id) && defined(get_prefix_id($plugin_config, $cidr, $vrf_id, $noerr))) {
         return if $noerr;
         die "prefix $cidr already exists in netbox";
     }
 
-    eval { netbox_api_request($plugin_config, "POST", "/ipam/prefixes/", { prefix => $cidr }); };
+    $vrf_id //= add_zone_namespace($plugin_config, $subnet->{zone}, $noerr);
+    return if !defined($vrf_id) && $noerr;
+
+    eval {
+        netbox_api_request(
+            $plugin_config,
+            "POST",
+            "/ipam/prefixes/",
+            { prefix => $cidr, vrf => $vrf_id },
+        );
+    };
     if ($@) {
         return if $noerr;
         die "error adding subnet to ipam: $@";
@@ -91,7 +101,7 @@ sub add_subnet {
 
     my $dhcp_ranges = PVE::Network::SDN::Subnets::get_dhcp_ranges($subnet);
     for my $dhcp_range (@$dhcp_ranges) {
-        add_dhcp_range($plugin_config, $dhcp_range, $noerr);
+        add_dhcp_range($plugin_config, $dhcp_range, $vrf_id, $noerr);
     }
 }
 
@@ -122,6 +132,8 @@ sub update_subnet {
 
     my $to_delete_ids = ();
 
+    my (undef, $vrf_id) = subnet_prefix($plugin_config, $subnet, $noerr);
+
     # delete first so we don't get errors with overlapping ranges
     for my $dhcp_range (@$old_dhcp_ranges) {
         my $hash = $hash_range->($dhcp_range);
@@ -130,7 +142,7 @@ sub update_subnet {
             next;
         }
 
-        my $internalid = get_iprange_id($plugin_config, $dhcp_range, $noerr);
+        my $internalid = get_iprange_id($plugin_config, $dhcp_range, $vrf_id, $noerr);
 
         # definedness check, because ID could be 0
         if (!defined($internalid)) {
@@ -145,7 +157,7 @@ sub update_subnet {
     for my $dhcp_range (@$new_dhcp_ranges) {
         my $hash = $hash_range->($dhcp_range);
 
-        add_dhcp_range($plugin_config, $dhcp_range, $noerr)
+        add_dhcp_range($plugin_config, $dhcp_range, $vrf_id, $noerr)
             if !exists($old_lookup->{$hash});
     }
 }
@@ -155,7 +167,7 @@ sub del_subnet {
 
     my $cidr = $subnet->{cidr};
 
-    my $internalid = get_prefix_id($plugin_config, $cidr, $noerr);
+    my ($internalid, $vrf_id) = subnet_prefix($plugin_config, $subnet, $noerr);
 
     # definedness check, because ID could be 0
     if (!defined($internalid)) {
@@ -163,7 +175,7 @@ sub del_subnet {
         return;
     }
 
-    if (!is_prefix_empty($plugin_config, $cidr, $noerr)) {
+    if (!is_prefix_empty($plugin_config, $cidr, $vrf_id, $noerr)) {
         return if $noerr;
         die "not deleting prefix $cidr because it still contains entries";
     }
@@ -176,7 +188,7 @@ sub del_subnet {
 
     my $dhcp_ranges = PVE::Network::SDN::Subnets::get_dhcp_ranges($subnet);
     for my $dhcp_range (@$dhcp_ranges) {
-        my $internalid = get_iprange_id($plugin_config, $dhcp_range, $noerr);
+        my $internalid = get_iprange_id($plugin_config, $dhcp_range, $vrf_id, $noerr);
 
         # definedness check, because ID could be 0
         if (!defined($internalid)) {
@@ -215,6 +227,8 @@ sub add_ip {
         $description = "mac:$mac";
     }
 
+    my (undef, $vrf_id) = subnet_prefix($plugin_config, $subnet, $noerr);
+
     eval {
         my $params = {
             address => "$ip/$mask",
@@ -222,6 +236,7 @@ sub add_ip {
         };
 
         $params->{dns_name} = $hostname if $hostname;
+        $params->{vrf} = $vrf_id if defined($vrf_id);
 
         netbox_api_request($plugin_config, "POST", "/ipam/ip-addresses/", $params);
     };
@@ -229,7 +244,7 @@ sub add_ip {
     if ($@) {
         if ($is_gateway) {
             die "error add subnet ip to ipam: ip $ip already exist: $@"
-                if !is_ip_gateway($plugin_config, $ip, $noerr);
+                if !is_ip_gateway($plugin_config, $ip, $vrf_id, $noerr);
         } elsif (!$noerr) {
             die "error add subnet ip to ipam: ip already exist: $@";
         }
@@ -259,7 +274,8 @@ sub update_ip {
         $description = "mac:$mac";
     }
 
-    my $ip_id = get_ip_id($plugin_config, $ip, $noerr);
+    my (undef, $vrf_id) = subnet_prefix($plugin_config, $subnet, $noerr);
+    my $ip_id = get_ip_id($plugin_config, $ip, $vrf_id, $noerr);
 
     # definedness check, because ID could be 0
     if (!defined($ip_id)) {
@@ -274,6 +290,7 @@ sub update_ip {
         };
 
         $params->{dns_name} = $hostname if $hostname;
+        $params->{vrf} = $vrf_id if defined($vrf_id);
 
         netbox_api_request($plugin_config, "PATCH", "/ipam/ip-addresses/$ip_id/", $params);
     };
@@ -287,7 +304,7 @@ sub add_next_freeip {
 
     my $cidr = $subnet->{cidr};
 
-    my $internalid = get_prefix_id($plugin_config, $cidr, $noerr);
+    my ($internalid) = subnet_prefix($plugin_config, $subnet, $noerr);
 
     # definedness check, because ID could be 0
     if (!defined($internalid)) {
@@ -323,7 +340,8 @@ sub add_next_freeip {
 sub add_range_next_freeip {
     my ($class, $plugin_config, $subnet, $range, $data, $noerr) = @_;
 
-    my $internalid = get_iprange_id($plugin_config, $range, $noerr);
+    my (undef, $vrf_id) = subnet_prefix($plugin_config, $subnet, $noerr);
+    my $internalid = get_iprange_id($plugin_config, $range, $vrf_id, $noerr);
 
     # definedness check, because ID could be 0
     if (!defined($internalid)) {
@@ -364,7 +382,8 @@ sub del_ip {
 
     return if !$ip;
 
-    my $ip_id = get_ip_id($plugin_config, $ip, $noerr);
+    my (undef, $vrf_id) = subnet_prefix($plugin_config, $subnet, $noerr);
+    my $ip_id = get_ip_id($plugin_config, $ip, $vrf_id, $noerr);
     if (!defined($ip_id)) {
         warn "could not find id for ip $ip";
         return;
@@ -393,12 +412,16 @@ sub get_ips_from_mac {
     my $ip4 = undef;
     my $ip6 = undef;
 
-    for my $cidr (sort map { $_->{cidr} } values %{ $subnets // {} }) {
+    for my $subnet (sort { $a->{cidr} cmp $b->{cidr} } values %{ $subnets // {} }) {
+        my $cidr = $subnet->{cidr};
+        my ($prefix_id, $vrf_id) = subnet_prefix($plugin_config, $subnet, $noerr);
+        next if !defined($prefix_id);
+        my $vrf = vrf_filter($vrf_id);
         my $data = eval {
             netbox_api_request(
                 $plugin_config,
                 "GET",
-                "/ipam/ip-addresses/?parent=$cidr&description__ic=$mac",
+                "/ipam/ip-addresses/?parent=$cidr&$vrf&description__ic=$mac",
             );
         };
         if ($@) {
@@ -435,14 +458,65 @@ sub on_update_hook {
 }
 
 # helpers
-sub get_prefix_id {
-    my ($config, $cidr, $noerr) = @_;
 
-    # we need to supply any IP inside the prefix, without supplying the mask, so
-    # just take the one from the cidr
-    my ($ip, undef) = split(/\//, $cidr);
+sub vrf_filter {
+    my ($vrf_id) = @_;
+
+    return defined($vrf_id) ? "vrf_id=$vrf_id" : "vrf_id=null";
+}
+
+my sub zone_vrf_name {
+    my ($zoneid) = @_;
+
+    return "pve-$zoneid";
+}
+
+sub get_zone_namespace {
+    my ($config, $zoneid, $noerr) = @_;
+
+    my $name = zone_vrf_name($zoneid);
+    my $result = eval { netbox_api_request($config, "GET", "/ipam/vrfs/?name=$name") };
+    if ($@) {
+        return if $noerr;
+        die "could not obtain ID for VRF $name: $@";
+    }
+
+    my $data = @{ $result->{results} }[0];
+    return $data->{id};
+}
+
+sub add_zone_namespace {
+    my ($config, $zoneid, $noerr) = @_;
+
+    my $name = zone_vrf_name($zoneid);
+    my $result = eval { netbox_api_request($config, "POST", "/ipam/vrfs/", { name => $name }) };
+    if ($@) {
+        return if $noerr;
+        die "could not create VRF $name: $@";
+    }
+
+    return $result->{id};
+}
+
+# a prefix from before sits in the global table and keeps its addresses there,
+# so a subnet is looked for in the zone's VRF first and in the global table
+# after it, with none as the VRF of the global table
+sub subnet_prefix {
+    my ($config, $subnet, $noerr) = @_;
+
+    my $vrf_id = get_zone_namespace($config, $subnet->{zone}, $noerr);
+    if (defined($vrf_id)) {
+        my $prefix_id = get_prefix_id($config, $subnet->{cidr}, $vrf_id, $noerr);
+        return ($prefix_id, $vrf_id) if defined($prefix_id);
+    }
+    return (get_prefix_id($config, $subnet->{cidr}, undef, $noerr), undef);
+}
+
+sub get_prefix_id {
+    my ($config, $cidr, $vrf_id, $noerr) = @_;
 
-    my $result = eval { netbox_api_request($config, "GET", "/ipam/prefixes/?q=$ip") };
+    my $vrf = vrf_filter($vrf_id);
+    my $result = eval { netbox_api_request($config, "GET", "/ipam/prefixes/?prefix=$cidr&$vrf") };
     if ($@) {
         return if $noerr;
         die "could not obtain ID for prefix $cidr: $@";
@@ -453,13 +527,14 @@ sub get_prefix_id {
 }
 
 sub get_iprange_id {
-    my ($config, $range, $noerr) = @_;
+    my ($config, $range, $vrf_id, $noerr) = @_;
 
+    my $vrf = vrf_filter($vrf_id);
     my $result = eval {
         netbox_api_request(
             $config,
             "GET",
-            "/ipam/ip-ranges/?start_address=$range->{'start-address'}&end_address=$range->{'end-address'}",
+            "/ipam/ip-ranges/?start_address=$range->{'start-address'}&end_address=$range->{'end-address'}&$vrf",
         );
     };
     if ($@) {
@@ -473,9 +548,10 @@ sub get_iprange_id {
 }
 
 sub get_ip_id {
-    my ($config, $ip, $noerr) = @_;
+    my ($config, $ip, $vrf_id, $noerr) = @_;
 
-    my $result = eval { netbox_api_request($config, "GET", "/ipam/ip-addresses/?q=$ip") };
+    my $vrf = vrf_filter($vrf_id);
+    my $result = eval { netbox_api_request($config, "GET", "/ipam/ip-addresses/?q=$ip&$vrf") };
     if ($@) {
         return if $noerr;
         die "could not obtain ID for IP $ip: $@";
@@ -486,9 +562,10 @@ sub get_ip_id {
 }
 
 sub is_ip_gateway {
-    my ($config, $ip, $noerr) = @_;
+    my ($config, $ip, $vrf_id, $noerr) = @_;
 
-    my $result = eval { netbox_api_request($config, "GET", "/ipam/ip-addresses/?q=$ip") };
+    my $vrf = vrf_filter($vrf_id);
+    my $result = eval { netbox_api_request($config, "GET", "/ipam/ip-addresses/?q=$ip&$vrf") };
     if ($@) {
         return if $noerr;
         die "could not obtain ipam entry for address $ip: $@";
@@ -499,9 +576,11 @@ sub is_ip_gateway {
 }
 
 sub is_prefix_empty {
-    my ($config, $cidr, $noerr) = @_;
+    my ($config, $cidr, $vrf_id, $noerr) = @_;
 
-    my $result = eval { netbox_api_request($config, "GET", "/ipam/ip-addresses/?parent=$cidr") };
+    my $vrf = vrf_filter($vrf_id);
+    my $result =
+        eval { netbox_api_request($config, "GET", "/ipam/ip-addresses/?parent=$cidr&$vrf") };
     if ($@) {
         return if $noerr;
         die "could not query children for prefix $cidr: $@";
diff --git a/src/PVE/Network/SDN/Ipams/PhpIpamPlugin.pm b/src/PVE/Network/SDN/Ipams/PhpIpamPlugin.pm
index e1b30e293ab3..efc2b15f6a91 100644
--- a/src/PVE/Network/SDN/Ipams/PhpIpamPlugin.pm
+++ b/src/PVE/Network/SDN/Ipams/PhpIpamPlugin.pm
@@ -2,6 +2,8 @@ package PVE::Network::SDN::Ipams::PhpIpamPlugin;
 
 use strict;
 use warnings;
+use Math::BigInt;
+use Net::IP;
 use PVE::INotify;
 use PVE::Cluster;
 use PVE::Tools;
@@ -47,15 +49,21 @@ sub add_subnet {
 
     my $url = $plugin_config->{url};
     my $token = $plugin_config->{token};
-    my $section = $plugin_config->{section};
     my $fingerprint = $plugin_config->{fingerprint};
     my $headers = ['Content-Type' => 'application/json; charset=UTF-8', 'Token' => $token];
 
+    # a subnet being added is new to its zone, so only the zone's section is
+    # searched, not the configured one the subnets from before sit in
+    my $section = get_zone_namespace($plugin_config, $subnet->{zone}, $headers);
+    $section //= shared_section($plugin_config, $headers);
+
     #search subnet
-    my $internalid = eval { get_prefix_id($url, $cidr, $headers) };
+    my $internalid = eval { get_prefix_id($url, $cidr, $headers, $section) };
 
     #create subnet
     if (!$internalid) {
+        $section //= add_zone_namespace($plugin_config, $subnet->{zone}, $headers, $noerr);
+        return if !defined($section);
         my $params = {
             subnet => $network,
             mask => $mask,
@@ -85,7 +93,7 @@ sub del_subnet {
     my $fingerprint = $plugin_config->{fingerprint};
     my $headers = ['Content-Type' => 'application/json; charset=UTF-8', 'Token' => $token];
 
-    my $internalid = get_prefix_id($url, $cidr, $headers);
+    my $internalid = subnet_id($plugin_config, $subnet, $headers);
     return if !$internalid;
 
     return; #fixme: check that prefix is empty exluding gateway, before delete
@@ -118,7 +126,7 @@ sub add_ip {
     my $fingerprint = $plugin_config->{fingerprint};
     my $headers = ['Content-Type' => 'application/json; charset=UTF-8', 'Token' => $token];
 
-    my $internalid = get_prefix_id($url, $cidr, $headers);
+    my $internalid = subnet_id($plugin_config, $subnet, $headers);
 
     my $params = {
         ip => $ip,
@@ -138,7 +146,7 @@ sub add_ip {
     if ($@) {
         if ($is_gateway) {
             die "error add subnet ip to ipam: ip $ip already exist: $@"
-                if !is_ip_gateway($url, $ip, $headers) && !$noerr;
+                if !is_ip_gateway($url, $ip, $headers, $internalid) && !$noerr;
         } else {
             die "error add subnet ip to ipam: ip $ip already exist: $@" if !$noerr;
         }
@@ -165,7 +173,7 @@ sub update_ip {
     my $fingerprint = $plugin_config->{fingerprint};
     my $headers = ['Content-Type' => 'application/json; charset=UTF-8', 'Token' => $token];
 
-    my $ip_id = get_ip_id($url, $ip, $headers);
+    my $ip_id = get_ip_id($url, $ip, $headers, subnet_id($plugin_config, $subnet, $headers));
     die "can't find ip address in ipam\n" if !$ip_id;
 
     my $params = {
@@ -196,7 +204,7 @@ sub add_next_freeip {
     my $fingerprint = $plugin_config->{fingerprint};
     my $headers = ['Content-Type' => 'application/json; charset=UTF-8', 'Token' => $token];
 
-    my $internalid = get_prefix_id($url, $cidr, $headers);
+    my $internalid = subnet_id($plugin_config, $subnet, $headers);
 
     my $params = {
         hostname => $hostname,
@@ -246,7 +254,7 @@ sub del_ip {
     my $fingerprint = $plugin_config->{fingerprint};
     my $headers = ['Content-Type' => 'application/json; charset=UTF-8', 'Token' => $token];
 
-    my $ip_id = get_ip_id($url, $ip, $headers);
+    my $ip_id = get_ip_id($url, $ip, $headers, subnet_id($plugin_config, $subnet, $headers));
     return if !$ip_id;
     if ($mac) {
         my $record = eval {
@@ -273,6 +281,18 @@ sub del_ip {
     return 1;
 }
 
+# an address comes back in its text form or as the decimal phpIPAM stores
+my sub address_of_hit {
+    my ($hit, $family) = @_;
+
+    my $ip = $hit->{ip} // $hit->{ip_addr};
+    return undef if !defined($ip);
+    return $ip if $ip !~ m/^\d+$/;
+    my $bin = Net::IP::ip_inttobin(Math::BigInt->new($ip), $family) // return undef;
+    $ip = Net::IP::ip_bintoip($bin, $family);
+    return $family == 6 ? Net::IP::ip_compress_address($ip, 6) : $ip;
+}
+
 sub get_ips_from_mac {
     my ($class, $plugin_config, $mac, $zoneid, $subnets) = @_;
 
@@ -284,7 +304,15 @@ sub get_ips_from_mac {
     my $ip4 = undef;
     my $ip6 = undef;
 
-    my $ips = eval {
+    # a hit names its subnet, and only the vnet's subnets count
+    my %wanted;
+    for my $subnet (values %{ $subnets // {} }) {
+        my $id = eval { subnet_id($plugin_config, $subnet, $headers) } // next;
+        $wanted{$id} = Net::IP::ip_is_ipv4($subnet->{network}) ? 4 : 6;
+    }
+    return if !%wanted;
+
+    my $result = eval {
         PVE::Network::SDN::api_request(
             "GET",
             "$url/addresses/search_mac/$mac",
@@ -295,17 +323,14 @@ sub get_ips_from_mac {
     };
     return if $@;
 
-    #fixme
-    die "parsing of result not yet implemented";
-
-    for my $ip (@$ips) {
-        #        if ($ip->{family}->{value} == 4 && !$ip4) {
-        #            ($ip4, undef) = split(/\//, $ip->{address});
-        #        }
-        #
-        #        if ($ip->{family}->{value} == 6 && !$ip6) {
-        #            ($ip6, undef) = split(/\//, $ip->{address});
-        #        }
+    for my $hit (@{ $result->{data} // [] }) {
+        my $family = $wanted{ $hit->{subnetId} // '' } // next;
+        my $ip = address_of_hit($hit, $family) // next;
+        if ($family == 4) {
+            $ip4 //= $ip;
+        } else {
+            $ip6 //= $ip;
+        }
     }
 
     return ($ip4, $ip6);
@@ -338,27 +363,109 @@ sub on_update_hook {
 
 #helpers
 
+# a zone's section is a sub-section of the configured one, so SDN writes only
+# under the section it was given. phpIPAM wants at least three characters in a
+# section name, a zone id may have two
+my sub zone_section_name {
+    my ($zoneid) = @_;
+
+    return "pve-$zoneid";
+}
+
+sub get_zone_namespace {
+    my ($plugin_config, $zoneid, $headers) = @_;
+
+    my $name = zone_section_name($zoneid);
+    my $result = eval {
+        PVE::Network::SDN::api_request(
+            "GET",
+            "$plugin_config->{url}/sections/$name/",
+            $headers,
+            undef,
+            $plugin_config->{fingerprint},
+        );
+    };
+    if (my $err = $@) {
+        # only a missing section means the zone has none yet, any other
+        # failure would send the zone's records to the configured section
+        return if $err =~ m/^Invalid response from server: 404 /;
+        die "could not look up section $name: $err";
+    }
+    my $section = $result->{data};
+    die "section $name is not under section $plugin_config->{section}\n"
+        if ($section->{masterSection} // 0) != $plugin_config->{section};
+    return $section->{id};
+}
+
+sub add_zone_namespace {
+    my ($plugin_config, $zoneid, $headers, $noerr) = @_;
+
+    my $name = zone_section_name($zoneid);
+    my $result = eval {
+        PVE::Network::SDN::api_request(
+            "POST",
+            "$plugin_config->{url}/sections/",
+            $headers,
+            { name => $name, masterSection => $plugin_config->{section} },
+            $plugin_config->{fingerprint},
+        );
+    };
+    if ($@) {
+        return if $noerr;
+        die "could not create section $name: $@";
+    }
+    return $result->{id};
+}
+
+# phpIPAM nests sections one level deep, so a configured section that is a
+# sub-section itself gets no zone sections and every zone's subnets stay in it
+sub shared_section {
+    my ($plugin_config, $headers) = @_;
+
+    my $result = PVE::Network::SDN::api_request(
+        "GET",
+        "$plugin_config->{url}/sections/$plugin_config->{section}/",
+        $headers,
+        undef,
+        $plugin_config->{fingerprint},
+    );
+    return ($result->{data}->{masterSection} // 0) ? $plugin_config->{section} : undef;
+}
+
+# a subnet from before sits in the configured section, so a subnet is looked
+# for in the zone's section first and in the configured one after it
+sub subnet_id {
+    my ($plugin_config, $subnet, $headers) = @_;
+
+    my $section = get_zone_namespace($plugin_config, $subnet->{zone}, $headers);
+    my $url = $plugin_config->{url};
+    return get_prefix_id($url, $subnet->{cidr}, $headers, $section)
+        // get_prefix_id($url, $subnet->{cidr}, $headers, $plugin_config->{section});
+}
+
 sub get_prefix_id {
-    my ($url, $cidr, $headers) = @_;
+    my ($url, $cidr, $headers, $section) = @_;
 
+    return undef if !defined($section);
     my $result = PVE::Network::SDN::api_request("GET", "$url/subnets/cidr/$cidr", $headers);
-    my $data = @{ $result->{data} }[0];
-    my $internalid = $data->{id};
-    return $internalid;
+    my ($data) = grep { $_->{sectionId} == $section } @{ $result->{data} // [] };
+    return $data->{id};
 }
 
 sub get_ip_id {
-    my ($url, $ip, $headers) = @_;
+    my ($url, $ip, $headers, $subnet_id) = @_;
     my $result = PVE::Network::SDN::api_request("GET", "$url/addresses/search/$ip", $headers);
-    my $data = @{ $result->{data} }[0];
+    my ($data) =
+        grep { !defined($subnet_id) || $_->{subnetId} == $subnet_id } @{ $result->{data} // [] };
     my $ip_id = $data->{id};
     return $ip_id;
 }
 
 sub is_ip_gateway {
-    my ($url, $ip, $headers) = @_;
+    my ($url, $ip, $headers, $subnet_id) = @_;
     my $result = PVE::Network::SDN::api_request("GET", "$url/addresses/search/$ip", $headers);
-    my $data = @{ $result->{data} }[0];
+    my ($data) =
+        grep { !defined($subnet_id) || $_->{subnetId} == $subnet_id } @{ $result->{data} // [] };
     my $is_gateway = $data->{is_gateway};
     return $is_gateway;
 }
diff --git a/src/test/ipams/netbox/expected.add_ip b/src/test/ipams/netbox/expected.add_ip
index cd46c53f1566..f54f8d05b16e 100644
--- a/src/test/ipams/netbox/expected.add_ip
+++ b/src/test/ipams/netbox/expected.add_ip
@@ -1,5 +1,5 @@
 bless( {
-                  '_content' => '{"address":"10.0.0.1/24","description":"gateway","dns_name":"myhostname"}',
+                  '_content' => '{"address":"10.0.0.1/24","description":"gateway","dns_name":"myhostname","vrf":1}',
                   '_headers' => bless( {
                                          'authorization' => 'token 0123456789abcdef0123456789abcdef01234567',
                                          'content-type' => 'application/json; charset=UTF-8'
diff --git a/src/test/ipams/netbox/expected.add_ip_notgateway b/src/test/ipams/netbox/expected.add_ip_notgateway
index 5ea43cd65296..327efbab8c6b 100644
--- a/src/test/ipams/netbox/expected.add_ip_notgateway
+++ b/src/test/ipams/netbox/expected.add_ip_notgateway
@@ -1,5 +1,5 @@
 bless( {
-                  '_content' => '{"address":"10.0.0.1/24","description":"mac:da:65:8f:18:9b:6f","dns_name":"myhostname"}',
+                  '_content' => '{"address":"10.0.0.1/24","description":"mac:da:65:8f:18:9b:6f","dns_name":"myhostname","vrf":1}',
                   '_headers' => bless( {
                                          'authorization' => 'token 0123456789abcdef0123456789abcdef01234567',
                                          'content-type' => 'application/json; charset=UTF-8'
diff --git a/src/test/ipams/netbox/expected.add_subnet b/src/test/ipams/netbox/expected.add_subnet
index 6f21265a2a18..7c2785adf9cd 100644
--- a/src/test/ipams/netbox/expected.add_subnet
+++ b/src/test/ipams/netbox/expected.add_subnet
@@ -1,5 +1,5 @@
 bless( {
-                  '_content' => '{"prefix":"10.0.0.0/24"}',
+                  '_content' => '{"prefix":"10.0.0.0/24","vrf":1}',
                   '_headers' => bless( {
                                          'authorization' => 'token 0123456789abcdef0123456789abcdef01234567',
                                          'content-type' => 'application/json; charset=UTF-8'
diff --git a/src/test/ipams/netbox/expected.update_ip b/src/test/ipams/netbox/expected.update_ip
index d3bc1addbdea..8a6e0a8cbbad 100644
--- a/src/test/ipams/netbox/expected.update_ip
+++ b/src/test/ipams/netbox/expected.update_ip
@@ -1,5 +1,5 @@
 bless( {
-                  '_content' => '{"address":"10.0.0.1/24","description":"gateway","dns_name":"myhostname"}',
+                  '_content' => '{"address":"10.0.0.1/24","description":"gateway","dns_name":"myhostname","vrf":1}',
                   '_headers' => bless( {
                                          'authorization' => 'token 0123456789abcdef0123456789abcdef01234567',
                                          'content-type' => 'application/json; charset=UTF-8'
diff --git a/src/test/run_test_ipams.pl b/src/test/run_test_ipams.pl
index 0d3fed120d3f..4dbc78720163 100755
--- a/src/test/run_test_ipams.pl
+++ b/src/test/run_test_ipams.pl
@@ -92,6 +92,9 @@ foreach my $path (@plugins) {
     my $plugin = PVE::Network::SDN::Ipams::Plugin->lookup($plugin_config->{type});
     my $sdn_ipam_plugin = Test::MockModule->new($plugin);
     $sdn_ipam_plugin->mock(
+        get_zone_namespace => sub {
+            return 1;
+        },
         get_prefix_id => sub {
             return 1;
         },
@@ -219,6 +222,115 @@ foreach my $path (@plugins) {
 
 }
 
+# the phpipam MAC lookup, with the addresses as the decimals phpipam hands out
+{
+    my $plugin = 'PVE::Network::SDN::Ipams::PhpIpamPlugin';
+    my $mocked = Test::MockModule->new($plugin);
+    $mocked->mock(subnet_id => sub { my (undef, $subnet) = @_; return $subnet->{id}; });
+    my $sdn_module = Test::MockModule->new("PVE::Network::SDN");
+    $sdn_module->mock(
+        api_request => sub {
+            return {
+                data => [
+                    { ip => 171048980, subnetId => 7 },
+                    { ip => '10.60.0.20', subnetId => 8 },
+                    { ip => '55832768119825001489588427028103692289', subnetId => 9 },
+                ],
+            };
+        },
+    );
+    my $subnets = {
+        7 => { id => 7, cidr => '10.50.0.0/24', network => '10.50.0.0' },
+        9 => { id => 9, cidr => '2a01::/64', network => '2a01::' },
+    };
+    my @ips = $plugin->get_ips_from_mac(
+        { url => 'u', token => 't' }, 'da:65:8f:18:9b:6f', 'z', $subnets,
+    );
+    is_deeply(
+        \@ips,
+        ['10.50.0.20', '2a01::1'],
+        'phpipam lookup converts decimal addresses by the subnet family',
+    );
+}
+
+# a range the zone's namespace does not hold is created there, also when a
+# subnet from before holds it in the global table or the configured section
+{
+    my $subnet = {
+        zone => 'z',
+        cidr => '10.0.0.0/24',
+        network => '10.0.0.0',
+        mask => '24',
+    };
+    my @requests;
+    my $sdn_module = Test::MockModule->new("PVE::Network::SDN");
+    $sdn_module->mock(
+        api_request => sub {
+            my ($method, $url, undef, $data) = @_;
+            push @requests, [$method, $url, $data];
+            return {};
+        },
+    );
+
+    my $netbox = Test::MockModule->new('PVE::Network::SDN::Ipams::NetboxPlugin');
+    $netbox->mock(
+        get_zone_namespace => sub { return 2; },
+        get_prefix_id =>
+            sub { my (undef, undef, $vrf_id) = @_; return defined($vrf_id) ? undef : 5; },
+    );
+    PVE::Network::SDN::Ipams::NetboxPlugin->add_subnet(
+        { url => 'u', token => 't' },
+        'z-10.0.0.0-24',
+        $subnet,
+        1,
+    );
+    is_deeply(
+        $requests[0],
+        ['POST', 'u/ipam/prefixes/', { prefix => '10.0.0.0/24', vrf => 2 }],
+        'netbox adds a range the global table holds to the zone\'s VRF',
+    );
+
+    @requests = ();
+    my $phpipam = Test::MockModule->new('PVE::Network::SDN::Ipams::PhpIpamPlugin');
+    $phpipam->mock(
+        get_zone_namespace => sub { return 2; },
+        get_prefix_id => sub {
+            my (undef, undef, undef, $section) = @_;
+            return ($section // '') eq '2' ? undef : 5;
+        },
+    );
+    PVE::Network::SDN::Ipams::PhpIpamPlugin->add_subnet(
+        { url => 'u', token => 't', section => 1 },
+        'z-10.0.0.0-24',
+        $subnet,
+        1,
+    );
+    is_deeply(
+        $requests[0],
+        ['POST', 'u/subnets/', { subnet => '10.0.0.0', mask => '24', sectionId => 2 }],
+        'phpipam adds a range the configured section holds to the zone\'s section',
+    );
+}
+
+# the phpipam gateway check answers for the subnet it is asked about, also
+# when the address sits in another zone's section as well
+{
+    my $sdn_module = Test::MockModule->new("PVE::Network::SDN");
+    $sdn_module->mock(
+        api_request => sub {
+            return {
+                data => [
+                    { ip => '10.0.0.1', subnetId => 7, is_gateway => 1 },
+                    { ip => '10.0.0.1', subnetId => 8, is_gateway => 0 },
+                ],
+            };
+        },
+    );
+    my $is_gateway = \&PVE::Network::SDN::Ipams::PhpIpamPlugin::is_ip_gateway;
+    ok($is_gateway->('u', '10.0.0.1', [], 7), 'phpipam sees the gateway in its subnet');
+    ok(!$is_gateway->('u', '10.0.0.1', [], 8), 'and not in another subnet holding the address');
+}
+
 # an address released for a MAC stays when the external IPAM records it
 # for another MAC
 {
@@ -256,5 +368,114 @@ foreach my $path (@plugins) {
     }
 }
 
+# a zone's VRF is found and created under its prefixed name
+{
+    my @requests;
+    my $sdn_module = Test::MockModule->new("PVE::Network::SDN");
+    $sdn_module->mock(
+        api_request => sub {
+            my ($method, $url, undef, $data) = @_;
+            push @requests, [$method, $url, $data];
+            return { results => [{ id => 4 }], id => 4 };
+        },
+    );
+    my $config = { url => 'u', token => 't' };
+    PVE::Network::SDN::Ipams::NetboxPlugin::get_zone_namespace($config, 'z');
+    PVE::Network::SDN::Ipams::NetboxPlugin::add_zone_namespace($config, 'z');
+    is_deeply(
+        \@requests,
+        [
+            ['GET', 'u/ipam/vrfs/?name=pve-z', undef],
+            ['POST', 'u/ipam/vrfs/', { name => 'pve-z' }],
+        ],
+        'netbox finds and creates the zone\'s VRF under the name pve-<zone>',
+    );
+}
+
+# a zone without a section is told apart from a section lookup that failed
+{
+    my $error;
+    my $sdn_module = Test::MockModule->new("PVE::Network::SDN");
+    $sdn_module->mock(api_request => sub { die $error; });
+    my $lookup = \&PVE::Network::SDN::Ipams::PhpIpamPlugin::get_zone_namespace;
+    my $config = { url => 'u', token => 't' };
+
+    $error = "Invalid response from server: 404 Not Found\n";
+    is(scalar($lookup->($config, 'z', [])), undef, 'phpipam reports a missing section as none');
+    $error = "Invalid response from server: 503 Service Unavailable\n";
+    eval { $lookup->($config, 'z', []) };
+    like($@, qr/could not look up section pve-z/, 'and dies on any other failure');
+
+    # a section of the zone's name elsewhere in phpIPAM is not the zone's
+    $sdn_module->mock(api_request => sub { return { data => { id => 7, masterSection => 2 } }; });
+    eval { $lookup->({ url => 'u', token => 't', section => 1 }, 'z', []) };
+    like($@, qr/not under section 1/, 'a section outside the configured one is refused');
+    $sdn_module->mock(api_request => sub { return { data => { id => 7, masterSection => 1 } }; });
+    is(
+        $lookup->({ url => 'u', token => 't', section => 1 }, 'z', []),
+        7,
+        'and one under it is the zone\'s',
+    );
+}
+
+# a zone's section is created under the configured section
+{
+    my @requests;
+    my $sdn_module = Test::MockModule->new("PVE::Network::SDN");
+    $sdn_module->mock(
+        api_request => sub {
+            my ($method, $url, undef, $data) = @_;
+            push @requests, [$method, $url, $data];
+            return { id => 5 };
+        },
+    );
+    my $phpipam = Test::MockModule->new('PVE::Network::SDN::Ipams::PhpIpamPlugin');
+    $phpipam->mock(
+        get_zone_namespace => sub { return undef; },
+        get_prefix_id => sub { return undef; },
+    );
+    PVE::Network::SDN::Ipams::PhpIpamPlugin->add_subnet(
+        { url => 'u', token => 't', section => 1 },
+        'z-10.0.0.0/24',
+        { zone => 'z', cidr => '10.0.0.0/24', network => '10.0.0.0', mask => '24' },
+        1,
+    );
+    is_deeply(
+        [map { [$_->[0], $_->[1], $_->[2]] } @requests],
+        [
+            ['GET', 'u/sections/1/', undef],
+            ['POST', 'u/sections/', { name => 'pve-z', masterSection => 1 }],
+            ['POST', 'u/subnets/', { subnet => '10.0.0.0', mask => '24', sectionId => 5 }],
+        ],
+        'phpipam creates the zone\'s section under the configured one',
+    );
+
+    # a configured section that is a sub-section itself gets no zone sections
+    @requests = ();
+    $sdn_module->mock(
+        api_request => sub {
+            my ($method, $url, undef, $data) = @_;
+            push @requests, [$method, $url, $data];
+            return $url eq 'u/sections/1/'
+                ? { data => { id => 1, masterSection => 3 } }
+                : { id => 5 };
+        },
+    );
+    PVE::Network::SDN::Ipams::PhpIpamPlugin->add_subnet(
+        { url => 'u', token => 't', section => 1 },
+        'z-10.0.0.0/24',
+        { zone => 'z', cidr => '10.0.0.0/24', network => '10.0.0.0', mask => '24' },
+        1,
+    );
+    is_deeply(
+        [map { [$_->[0], $_->[1], $_->[2]] } @requests],
+        [
+            ['GET', 'u/sections/1/', undef],
+            ['POST', 'u/subnets/', { subnet => '10.0.0.0', mask => '24', sectionId => 1 }],
+        ],
+        'and keeps the subnets in a configured section that is a sub-section itself',
+    );
+}
+
 done_testing();
 
-- 
2.47.3





  parent reply	other threads:[~2026-11-02 11:07 UTC|newest]

Thread overview: 11+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-11-02 11:05 [PATCH docs/network 00/10] sdn: improve MAC cache, DHCP reservations and scope both to the vnet Hannes Laimer
2026-11-02 11:05 ` [PATCH pve-network 01/10] sdn: ipam: stop caching IPAM misses Hannes Laimer
2026-11-02 11:05 ` [PATCH pve-network 02/10] sdn: ipam: write the lookup's cache entry under the cluster lock Hannes Laimer
2026-11-02 11:05 ` [PATCH pve-network 03/10] sdn: ipam: read the MAC cache case-insensitively Hannes Laimer
2026-11-02 11:05 ` [PATCH pve-network 04/10] sdn: dhcp: replace a stale dnsmasq reservation instead of dropping it Hannes Laimer
2026-11-02 11:05 ` [PATCH pve-network 05/10] sdn: ipam: release an address the built-in IPAM no longer holds Hannes Laimer
2026-11-02 11:06 ` [PATCH pve-network 06/10] sdn: dhcp: update reservations when ip mappings change Hannes Laimer
2026-11-02 11:06 ` [PATCH pve-network 07/10] sdn: ipam: look a MAC up within the vnet's subnets Hannes Laimer
2026-11-02 11:06 ` [PATCH pve-network 08/10] sdn: ipam: keep the MAC cache per vnet Hannes Laimer
2026-11-02 11:06 ` Hannes Laimer [this message]
2026-11-02 11:06 ` [PATCH pve-docs 10/10] sdn: ipam: document the per-zone VRF and section in NetBox and phpIPAM 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=20261102110604.915693-10-h.laimer@proxmox.com \
    --to=h.laimer@proxmox.com \
    --cc=pve-devel@lists.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