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 v4 09/13] sdn: ipam: give each zone a VRF in NetBox and a section in phpIPAM
Date: Tue,  3 Nov 2026 19:55:55 +0100	[thread overview]
Message-ID: <20261103185559.420277-10-h.laimer@proxmox.com> (raw)
In-Reply-To: <20261103185559.420277-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. With a new option of
the IPAM entry, the subnets added to a zone go into the zone's own
namespace, a VRF in NetBox and a sub-section of the configured
section in phpIPAM. The option is off by default, since a node not
yet upgraded does not know the namespaces and keeps looking records
up by the address alone. A namespace is named after its zone and
created with its first subnet. A second option sets the start of the
names, pve by default, so clusters sharing an IPAM can keep theirs
apart.

An existing subnet is looked for in the zone's namespace first and in
the global table or the configured section after it. So subnets from
before are still found and nothing has to be moved. With the
namespaces on, adding a subnet that the global table or the
configured section already holds fails, since a copy in the zone's
namespace would hide its records. phpIPAM nests sections one level
deep, so a configured section that is a sub-section itself is shared
by every zone as before.

The phpIPAM lookup by MAC, unimplemented until now, returns the
addresses recorded for the MAC in the vnet's subnets. In phpIPAM a
failed lookup stops the operation unless the answer is not found, so
a failure cannot send the zone's records to the configured section.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/Network/SDN/Ipams/NetboxPlugin.pm     | 194 +++++--
 src/PVE/Network/SDN/Ipams/PhpIpamPlugin.pm    | 223 ++++++--
 src/PVE/Network/SDN/Ipams/Plugin.pm           |  17 +
 src/test/ipams/netbox/expected.add_ip         |   2 +-
 .../ipams/netbox/expected.add_ip_notgateway   |   2 +-
 src/test/ipams/netbox/expected.update_ip      |   2 +-
 src/test/run_test_ipams.pl                    | 533 ++++++++++++++++++
 7 files changed, 890 insertions(+), 83 deletions(-)

diff --git a/src/PVE/Network/SDN/Ipams/NetboxPlugin.pm b/src/PVE/Network/SDN/Ipams/NetboxPlugin.pm
index 84ce4382e0e7..30905aaef9ff 100644
--- a/src/PVE/Network/SDN/Ipams/NetboxPlugin.pm
+++ b/src/PVE/Network/SDN/Ipams/NetboxPlugin.pm
@@ -21,6 +21,8 @@ sub options {
         url => { optional => 0 },
         token => { optional => 0 },
         fingerprint => { optional => 1 },
+        'zone-namespaces' => { optional => 1 },
+        'zone-namespace-prefix' => { optional => 1 },
     };
 }
 
@@ -40,18 +42,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;
@@ -72,26 +71,60 @@ sub del_dhcp_range {
 
 # Plugin implementation
 
+my sub zone_vrf_name {
+    my ($plugin_config, $zoneid) = @_;
+
+    my $prefix = $plugin_config->{'zone-namespace-prefix'} // 'pve';
+    return "$prefix-$zoneid";
+}
+
 sub add_subnet {
     my ($class, $plugin_config, $subnetid, $subnet, $noerr) = @_;
 
     my $cidr = $subnet->{cidr};
     my $gateway = $subnet->{gateway};
 
-    if (get_prefix_id($plugin_config, $cidr, $noerr)) {
+    my ($vrf_id, $taken_over);
+    if ($plugin_config->{'zone-namespaces'}) {
+        # a prefix already in the zone's VRF is used, one in the global table
+        # is not taken over, since a copy in the VRF would hide its records
+        $vrf_id = get_zone_namespace($plugin_config, $subnet->{zone}, $noerr);
+        $taken_over = defined($vrf_id)
+            && defined(get_prefix_id($plugin_config, $cidr, $vrf_id, $noerr));
+        if (defined(get_prefix_id($plugin_config, $cidr, undef, $noerr))) {
+            return if $noerr;
+            my $vrf = zone_vrf_name($plugin_config, $subnet->{zone});
+            die "prefix $cidr exists in the global table of NetBox, move it with its"
+                . " addresses and ranges into the VRF of its zone, $vrf for this one, or"
+                . " remove it first\n";
+        }
+
+        $vrf_id //= add_zone_namespace($plugin_config, $subnet->{zone}, $noerr);
+        return if !defined($vrf_id) && $noerr;
+    } elsif (defined(get_prefix_id($plugin_config, $cidr, undef, $noerr))) {
         return if $noerr;
         die "prefix $cidr already exists in netbox";
     }
 
-    eval { netbox_api_request($plugin_config, "POST", "/ipam/prefixes/", { prefix => $cidr }); };
-    if ($@) {
-        return if $noerr;
-        die "error adding subnet to ipam: $@";
+    if (!$taken_over) {
+        eval {
+            my $params = { prefix => $cidr };
+            $params->{vrf} = $vrf_id if defined($vrf_id);
+            netbox_api_request($plugin_config, "POST", "/ipam/prefixes/", $params);
+        };
+        if ($@) {
+            return if $noerr;
+            die "error adding subnet to ipam: $@";
+        }
     }
 
     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);
+        # a prefix moved into the VRF came with its ranges
+        next
+            if $taken_over
+            && defined(get_iprange_id($plugin_config, $dhcp_range, $vrf_id, $noerr));
+        add_dhcp_range($plugin_config, $dhcp_range, $vrf_id, $noerr);
     }
 }
 
@@ -122,6 +155,8 @@ sub update_subnet {
 
     my $to_delete_ids = ();
 
+    my (undef, $vrf_id) = subnet_prefix($plugin_config, $subnet, $noerr, 1) or return;
+
     # 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 +165,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 +180,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 +190,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 +198,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 +211,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 +250,8 @@ sub add_ip {
         $description = "mac:$mac";
     }
 
+    my (undef, $vrf_id) = subnet_prefix($plugin_config, $subnet, $noerr, 1) or return;
+
     eval {
         my $params = {
             address => "$ip/$mask",
@@ -222,6 +259,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 +267,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 +297,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, 1) or return;
+    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 +313,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,13 +327,7 @@ sub add_next_freeip {
 
     my $cidr = $subnet->{cidr};
 
-    my $internalid = get_prefix_id($plugin_config, $cidr, $noerr);
-
-    # definedness check, because ID could be 0
-    if (!defined($internalid)) {
-        return if $noerr;
-        die "could not find id for prefix $cidr";
-    }
+    my ($internalid) = subnet_prefix($plugin_config, $subnet, $noerr, 1) or return;
 
     my $description = undef;
     $description = "mac:$mac" if $mac;
@@ -323,7 +357,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, 1) or return;
+    my $internalid = get_iprange_id($plugin_config, $range, $vrf_id, $noerr);
 
     # definedness check, because ID could be 0
     if (!defined($internalid)) {
@@ -364,7 +399,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, 1) or return;
+    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;
@@ -397,12 +433,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 ($@) {
@@ -439,14 +479,67 @@ 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";
+}
+
+sub get_zone_namespace {
+    my ($config, $zoneid, $noerr) = @_;
+
+    my $name = zone_vrf_name($config, $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($config, $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. A subnet with no prefix
+# anywhere stops an operation that needs one, since the address and range
+# operations would act on the global table where another zone's records may
+# sit
+sub subnet_prefix {
+    my ($config, $subnet, $noerr, $needed) = @_;
+
+    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);
+    }
+    my $prefix_id = get_prefix_id($config, $subnet->{cidr}, undef, $noerr);
+    if ($needed && !defined($prefix_id)) {
+        return if $noerr;
+        die "could not find id for prefix $subnet->{cidr}";
+    }
+    return ($prefix_id, 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: $@";
@@ -457,13 +550,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 ($@) {
@@ -477,9 +571,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: $@";
@@ -490,9 +585,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: $@";
@@ -503,9 +599,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 e969c3dcdf4f..63f5f2a1ffd5 100644
--- a/src/PVE/Network/SDN/Ipams/PhpIpamPlugin.pm
+++ b/src/PVE/Network/SDN/Ipams/PhpIpamPlugin.pm
@@ -2,6 +2,9 @@ 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;
@@ -35,11 +38,23 @@ sub options {
         token => { optional => 0 },
         section => { optional => 0 },
         fingerprint => { optional => 1 },
+        'zone-namespaces' => { optional => 1 },
+        'zone-namespace-prefix' => { optional => 1 },
     };
 }
 
 # Plugin implementation
 
+# a zone's section is a sub-section of the configured one, so SDN writes only
+# under the section it was given. Section names are global in phpIPAM, so
+# clusters sharing one set a prefix of their own
+my sub zone_section_name {
+    my ($plugin_config, $zoneid) = @_;
+
+    my $prefix = $plugin_config->{'zone-namespace-prefix'} // 'pve';
+    return "$prefix-$zoneid";
+}
+
 sub add_subnet {
     my ($class, $plugin_config, $subnetid, $subnet, $noerr) = @_;
 
@@ -49,15 +64,42 @@ 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];
 
+    # with zone namespaces a subnet being added is new to its zone, so it goes
+    # into the zone's section, and a subnet from before in the configured
+    # section is not taken over, since a copy in the zone's section would hide
+    # its records
+    my $section = $plugin_config->{section};
+    if ($plugin_config->{'zone-namespaces'}) {
+        $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) };
+    if (my $err = $@) {
+        return if $noerr;
+        die $err;
+    }
+    my $in_shared = defined($section) && $section == $plugin_config->{section};
+    if (
+        !$internalid
+        && !$in_shared
+        && defined(get_prefix_id($url, $cidr, $headers, $plugin_config->{section}))
+    ) {
+        return if $noerr;
+        my $name = zone_section_name($plugin_config, $subnet->{zone});
+        die "subnet $cidr exists in section $plugin_config->{section} of phpIPAM, move it"
+            . " into the sub-section of its zone under that section, $name for this one,"
+            . " or remove it first\n";
+    }
 
     #create subnet
     if (!$internalid) {
+        $section //= add_zone_namespace($plugin_config, $subnet->{zone}, $headers, $noerr);
+        return if !defined($section);
         my $params = {
             subnet => $network,
             mask => $mask,
@@ -87,7 +129,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
@@ -120,7 +162,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,
@@ -140,7 +182,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;
         }
@@ -167,7 +209,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 = {
@@ -198,7 +240,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,
@@ -248,7 +290,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 {
@@ -279,6 +321,24 @@ sub del_ip {
     return 1;
 }
 
+# phpIPAM answers a lookup that finds nothing with a 404
+my sub not_found {
+    my ($err) = @_;
+    return $err =~ m/^Invalid response from server: 404 /;
+}
+
+# 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) = @_;
 
@@ -290,7 +350,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 = 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",
@@ -299,19 +367,19 @@ sub get_ips_from_mac {
             $fingerprint,
         );
     };
-    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});
-        #        }
+    if (my $err = $@) {
+        return if not_found($err);
+        die "could not look up the addresses of $mac: $err";
+    }
+
+    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);
@@ -344,27 +412,118 @@ sub on_update_hook {
 
 #helpers
 
-sub get_prefix_id {
-    my ($url, $cidr, $headers) = @_;
+sub get_zone_namespace {
+    my ($plugin_config, $zoneid, $headers) = @_;
 
-    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 $name = zone_section_name($plugin_config, $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 not_found($err);
+        die "could not look up section $name: $err";
+    }
+    # section names are global in phpIPAM, so one of the zone's name under
+    # another parent is another cluster's, and the zone has none of its own
+    my $section = $result->{data};
+    return 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($plugin_config, $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 (my $err = $@) {
+        return if $noerr;
+        my $taken = eval {
+            PVE::Network::SDN::api_request(
+                "GET",
+                "$plugin_config->{url}/sections/$name/",
+                $headers,
+                undef,
+                $plugin_config->{fingerprint},
+            );
+        };
+        die "section $name exists under another section in phpIPAM, move it under"
+            . " section $plugin_config->{section} or remove it\n"
+            if $taken;
+        die "could not create section $name: $err";
+    }
+    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, $section) = @_;
+
+    return undef if !defined($section);
+    my $result =
+        eval { PVE::Network::SDN::api_request("GET", "$url/subnets/cidr/$cidr", $headers) };
+    if (my $err = $@) {
+        return undef if not_found($err);
+        die "could not look up subnet $cidr: $err";
+    }
+    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/PVE/Network/SDN/Ipams/Plugin.pm b/src/PVE/Network/SDN/Ipams/Plugin.pm
index 6e23d5434d4b..4998957f1485 100644
--- a/src/PVE/Network/SDN/Ipams/Plugin.pm
+++ b/src/PVE/Network/SDN/Ipams/Plugin.pm
@@ -45,6 +45,23 @@ my $defaultData = {
             },
         ),
         fingerprint => get_standard_option('fingerprint-sha256', { optional => 1 }),
+        'zone-namespaces' => {
+            type => 'boolean',
+            description => "Keep the subnets of each zone in a namespace of its own, a VRF"
+                . " in NetBox and a sub-section in phpIPAM, so two zones can use the same"
+                . " range. Turn it on once every node runs a version that knows them.",
+            optional => 1,
+            default => 0,
+        },
+        'zone-namespace-prefix' => {
+            type => 'string',
+            description => "The names of the zone namespaces start with this, so clusters"
+                . " sharing an IPAM set one of their own.",
+            pattern => '[a-zA-Z0-9][a-zA-Z0-9_-]*',
+            maxLength => 32,
+            optional => 1,
+            default => 'pve',
+        },
     },
 };
 
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.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 f07b4bb4b486..75a700d7d604 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,196 @@ 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 MAC without addresses is a 404, any other failure fails the lookup
+    $sdn_module->mock(api_request => sub { die "Invalid response from server: 404 Not Found\n" });
+    is_deeply(
+        [
+            $plugin->get_ips_from_mac(
+                { url => 'u', token => 't' },
+                'da:65:8f:18:9b:6f',
+                'z',
+                $subnets,
+            ),
+        ],
+        [],
+        'phpipam lookup answers nothing for a MAC without addresses',
+    );
+    $sdn_module->mock(
+        api_request => sub { die "Invalid response from server: 503 Service Unavailable\n" });
+    eval {
+        $plugin->get_ips_from_mac(
+            { url => 'u', token => 't' },
+            'da:65:8f:18:9b:6f',
+            'z',
+            $subnets,
+        );
+    };
+    like($@, qr/could not look up the addresses/, 'and dies on any other failure');
+}
+
+# a range the zone's namespace does not hold is refused while a subnet from
+# before holds it in the global table or the configured section, since a copy
+# would hide its records, and the error names the way out
+{
+    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; },
+    );
+    eval {
+        PVE::Network::SDN::Ipams::NetboxPlugin->add_subnet(
+            { url => 'u', token => 't', 'zone-namespaces' => 1 },
+            'z-10.0.0.0-24',
+            $subnet,
+        );
+    };
+    like(
+        $@,
+        qr/move it .* into the VRF of its zone, pve-z for this one/,
+        'netbox refuses a range the global table holds',
+    );
+    is(scalar(@requests), 0, 'and creates nothing');
+
+    @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;
+        },
+    );
+    eval {
+        PVE::Network::SDN::Ipams::PhpIpamPlugin->add_subnet(
+            { url => 'u', token => 't', section => 1, 'zone-namespaces' => 1 },
+            'z-10.0.0.0-24',
+            $subnet,
+        );
+    };
+    like(
+        $@,
+        qr/move it into the sub-section of its zone .* pve-z for this one/,
+        'phpipam refuses a range the configured section holds',
+    );
+    is(scalar(@requests), 0, 'and creates nothing');
+}
+
+# a prefix already in the zone's VRF, moved there as the refusal advises, is
+# used by the subnet added next, and only the ranges it came without are created
+{
+    my $subnet = {
+        zone => 'z',
+        cidr => '10.0.0.0/24',
+        network => '10.0.0.0',
+        mask => '24',
+        'dhcp-range' => [
+            'start-address=10.0.0.10,end-address=10.0.0.20',
+            'start-address=10.0.0.30,end-address=10.0.0.40',
+        ],
+    };
+    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 => 7 }] }
+                if $url =~ m|/ipam/ip-ranges/\?start_address=10\.0\.0\.10&|;
+            return { results => [] };
+        },
+    );
+    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) ? 5 : undef; },
+    );
+    eval {
+        PVE::Network::SDN::Ipams::NetboxPlugin->add_subnet(
+            { url => 'u', token => 't', 'zone-namespaces' => 1 },
+            'z-10.0.0.0-24',
+            $subnet,
+        );
+    };
+    is($@, '', 'netbox uses a prefix already in the zone\'s VRF');
+    is_deeply(
+        [grep { $_->[0] eq 'POST' } @requests],
+        [[
+            'POST',
+            'u/ipam/ip-ranges/',
+            { start_address => '10.0.0.30', end_address => '10.0.0.40', vrf => 2 },
+        ]],
+        'creates only the range the VRF does not hold and no prefix',
+    );
+}
+
+# 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');
+    ok(!$is_gateway->('u', '10.0.0.1', [], undef), 'and not without a subnet to ask about');
+}
+
 # an address released for a MAC stays when the external IPAM records it
 # for another MAC
 {
@@ -268,5 +461,345 @@ foreach my $path (@plugins) {
     }
 }
 
+# an address operation on a subnet whose prefix is in neither the zone's VRF
+# nor the global table stops instead of acting on the global table
+{
+    my @requests;
+    my $sdn_module = Test::MockModule->new("PVE::Network::SDN");
+    $sdn_module->mock(
+        api_request => sub {
+            my ($method, $url) = @_;
+            push @requests, [$method, $url];
+            return { results => [] };
+        },
+    );
+    my $netbox = Test::MockModule->new('PVE::Network::SDN::Ipams::NetboxPlugin');
+    $netbox->mock(
+        get_zone_namespace => sub { return undef; },
+        get_prefix_id => sub { return undef; },
+    );
+    my $config = { url => 'u', token => 't' };
+    my $subnet = { zone => 'z', cidr => '10.0.0.0/24' };
+    my $plugin = 'PVE::Network::SDN::Ipams::NetboxPlugin';
+    my %ops = (
+        add_ip => sub {
+            $plugin->add_ip(
+                $config,
+                'z-10.0.0.0-24',
+                $subnet,
+                '10.0.0.5',
+                'h',
+                'da:65:8f:18:9b:6f',
+                'd',
+                0,
+            );
+        },
+        update_ip => sub {
+            $plugin->update_ip(
+                $config,
+                'z-10.0.0.0-24',
+                $subnet,
+                '10.0.0.5',
+                'h',
+                'da:65:8f:18:9b:6f',
+                'd',
+                0,
+            );
+        },
+        del_ip => sub { $plugin->del_ip($config, 'z-10.0.0.0-24', $subnet, '10.0.0.5') },
+    );
+    for my $op (sort keys %ops) {
+        @requests = ();
+        eval { $ops{$op}->() };
+        like($@, qr/could not find id for prefix 10.0.0.0\/24/,
+            "netbox $op stops without a prefix");
+        is(scalar(@requests), 0, "and sends nothing");
+    }
+}
+
+# without the zone namespaces a subnet goes where it went before, the global
+# table or the configured section, and no namespace is looked for or created
+{
+    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 $method eq 'GET' ? { results => [], data => [] } : { id => 5 };
+        },
+    );
+    my $subnet = { zone => 'z', cidr => '10.0.0.0/24', network => '10.0.0.0', mask => '24' };
+    PVE::Network::SDN::Ipams::NetboxPlugin->add_subnet(
+        { url => 'u', token => 't' },
+        'z-10.0.0.0-24',
+        $subnet,
+    );
+    is_deeply(
+        [grep { $_->[0] eq 'POST' } @requests],
+        [['POST', 'u/ipam/prefixes/', { prefix => '10.0.0.0/24' }]],
+        'netbox puts a subnet into the global table without the zone namespaces',
+    );
+    ok(!grep({ $_->[1] =~ m|/vrfs/| } @requests), 'and never asks for a VRF');
+
+    @requests = ();
+    PVE::Network::SDN::Ipams::PhpIpamPlugin->add_subnet(
+        { url => 'u', token => 't', section => 1 },
+        'z-10.0.0.0-24',
+        $subnet,
+    );
+    is_deeply(
+        [grep { $_->[0] eq 'POST' } @requests],
+        [['POST', 'u/subnets/', { subnet => '10.0.0.0', mask => '24', sectionId => 1 }]],
+        'phpipam puts a subnet into the configured section without the zone namespaces',
+    );
+    ok(!grep({ $_->[1] =~ m|/sections/| } @requests), 'and never asks for a section');
+}
+
+# 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,
+                data => { id => 4, masterSection => 1 },
+            };
+        },
+    );
+    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 prefix set on the entry keeps clusters sharing the IPAM apart
+    @requests = ();
+    PVE::Network::SDN::Ipams::NetboxPlugin::get_zone_namespace(
+        { %$config, 'zone-namespace-prefix' => 'lab' }, 'z',
+    );
+    is($requests[0]->[1], 'u/ipam/vrfs/?name=lab-z', 'and under the configured prefix');
+    is(
+        PVE::Network::SDN::Ipams::PhpIpamPlugin::get_zone_namespace(
+            { url => 'u', token => 't', section => 1, 'zone-namespace-prefix' => 'lab' },
+            'z',
+            [],
+        ),
+        4,
+        'phpipam looks the zone\'s section up under the configured prefix as well',
+    );
+    like($requests[-1]->[1], qr{/sections/lab-z/$}, 'by its name');
+}
+
+# a subnet from before is found in the global table while the zone's VRF holds
+# no prefix for its range, and in the VRF once it was moved there
+{
+    my $moved;
+    my $sdn_module = Test::MockModule->new("PVE::Network::SDN");
+    $sdn_module->mock(
+        api_request => sub {
+            my ($method, $url) = @_;
+            return { results => [{ id => 2 }] } if $url =~ m|/ipam/vrfs/\?name=pve-z$|;
+            if ($url =~ m|/ipam/prefixes/\?prefix=10\.0\.0\.0/24&vrf_id=(\S+)$|) {
+                my $vrf = $1;
+                return { results => [{ id => 5 }] } if $vrf eq '2' && $moved;
+                return { results => [{ id => 5 }] } if $vrf eq 'null' && !$moved;
+                return { results => [] };
+            }
+            die "unexpected request $method $url\n";
+        },
+    );
+    my $config = { url => 'u', token => 't' };
+    my $subnet = { zone => 'z', cidr => '10.0.0.0/24' };
+    is_deeply(
+        [PVE::Network::SDN::Ipams::NetboxPlugin::subnet_prefix($config, $subnet)],
+        [5, undef],
+        'netbox finds a prefix from before in the global table next to the zone\'s VRF',
+    );
+    $moved = 1;
+    is_deeply(
+        [PVE::Network::SDN::Ipams::NetboxPlugin::subnet_prefix($config, $subnet)],
+        [5, 2],
+        'and in the VRF once the prefix was moved there',
+    );
+}
+
+# the same for phpipam, where the subnet keeps its id across the move, so the
+# requests show which section answered
+{
+    my $moved;
+    my @cidr_requests;
+    my $sdn_module = Test::MockModule->new("PVE::Network::SDN");
+    $sdn_module->mock(
+        api_request => sub {
+            my ($method, $url) = @_;
+            return { data => { id => 2, masterSection => 1 } }
+                if $url =~ m|/sections/pve-z/$|;
+            if ($url =~ m|/subnets/cidr/10\.0\.0\.0/24$|) {
+                push @cidr_requests, $url;
+                return { data => [{ id => 5, sectionId => $moved ? 2 : 1 }] };
+            }
+            die "unexpected request $method $url\n";
+        },
+    );
+    my $config = { url => 'u', token => 't', section => 1 };
+    my $subnet = { zone => 'z', cidr => '10.0.0.0/24' };
+    is(
+        PVE::Network::SDN::Ipams::PhpIpamPlugin::subnet_id($config, $subnet, []),
+        5,
+        'phpipam finds a subnet from before in the configured section',
+    );
+    is(scalar(@cidr_requests), 2, 'after looking in the zone\'s section first');
+    $moved = 1;
+    @cidr_requests = ();
+    is(
+        PVE::Network::SDN::Ipams::PhpIpamPlugin::subnet_id($config, $subnet, []),
+        5,
+        'and in the zone\'s section once the subnet was moved there',
+    );
+    is(scalar(@cidr_requests), 1, 'without a second look');
+}
+
+# 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', section => 1 };
+
+    $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 another cluster's
+    $sdn_module->mock(api_request => sub { return { data => { id => 7, masterSection => 2 } }; });
+    is(
+        scalar($lookup->({ url => 'u', token => 't', section => 1 }, 'z', [])),
+        undef,
+        'a section outside the configured one is not the zone\'s',
+    );
+    # and creating the zone's own then fails with the reason
+    $sdn_module->mock(
+        api_request => sub {
+            my ($method) = @_;
+            die "Invalid response from server: 500 Internal Server Error\n"
+                if $method eq 'POST';
+            return { data => { id => 7, masterSection => 2 } };
+        },
+    );
+    eval {
+        PVE::Network::SDN::Ipams::PhpIpamPlugin::add_zone_namespace(
+            { url => 'u', token => 't', section => 1 },
+            'z',
+            [],
+        );
+    };
+    like(
+        $@,
+        qr/exists under another section/,
+        'creating a section another cluster has fails with the reason',
+    );
+    $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, 'zone-namespaces' => 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, 'zone-namespaces' => 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',
+    );
+
+    # a lookup that fails stops the creation, a copy would be made otherwise
+    @requests = ();
+    $sdn_module->mock(
+        api_request => sub {
+            my ($method, $url, undef, $data) = @_;
+            push @requests, [$method, $url, $data];
+            die "Invalid response from server: 503 Service Unavailable\n"
+                if $url =~ m|/subnets/cidr/|;
+            return { data => { id => 1, masterSection => 3 } };
+        },
+    );
+    $phpipam->unmock('get_prefix_id');
+    eval {
+        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' },
+        );
+    };
+    like($@, qr/could not look up subnet/, 'a failed subnet lookup stops the creation');
+    is(scalar(grep { $_->[0] eq 'POST' } @requests), 0, 'and nothing is created');
+}
+
 done_testing();
 
-- 
2.47.3





  parent reply	other threads:[~2026-11-03 18:58 UTC|newest]

Thread overview: 15+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-11-03 18:55 [PATCH docs/manager/network/qemu-server v4 00/13] sdn: improve MAC cache, DHCP reservations and scope both to the vnet Hannes Laimer
2026-11-03 18:55 ` [PATCH pve-network v4 01/13] sdn: ipam: stop caching IPAM misses Hannes Laimer
2026-11-03 18:55 ` [PATCH pve-network v4 02/13] sdn: ipam: cache a MAC lookup's answer under the cluster lock Hannes Laimer
2026-11-03 18:55 ` [PATCH pve-network v4 03/13] sdn: ipam: read the MAC cache case-insensitively Hannes Laimer
2026-11-03 18:55 ` [PATCH pve-network v4 04/13] sdn: dhcp: replace a stale dnsmasq reservation instead of dropping it Hannes Laimer
2026-11-03 18:55 ` [PATCH pve-network v4 05/13] sdn: ipam: release an address the built-in IPAM no longer holds Hannes Laimer
2026-11-03 18:55 ` [PATCH pve-network v4 06/13] sdn: dhcp: update reservations when IP mappings change Hannes Laimer
2026-11-03 18:55 ` [PATCH pve-network v4 07/13] sdn: ipam: look a MAC up within the vnet's subnets Hannes Laimer
2026-11-03 18:55 ` [PATCH pve-network v4 08/13] sdn: ipam: keep the MAC cache per vnet Hannes Laimer
2026-11-03 18:55 ` Hannes Laimer [this message]
2026-11-03 18:55 ` [PATCH qemu-server v4 10/13] network: sdn: push the DHCP mappings of a guest when it resumes Hannes Laimer
2026-11-03 18:55 ` [PATCH pve-manager v4 11/13] ui: sdn: dhcp tree: do not merge the same subnet in different zones Hannes Laimer
2026-11-03 18:55 ` [PATCH pve-manager v4 12/13] ui: sdn: ipam: add the zone namespace options Hannes Laimer
2026-11-03 18:55 ` [PATCH pve-docs v4 13/13] sdn: ipam: document zone namespaces in NetBox and phpIPAM Hannes Laimer
2026-11-04  9:56 ` superseded: [PATCH docs/manager/network/qemu-server v4 00/13] sdn: improve MAC cache, DHCP reservations and scope both to the vnet 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=20261103185559.420277-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