public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Stefan Hanreich <s.hanreich@proxmox.com>
To: lou.lecrivain@orange.fr, pve-devel@lists.proxmox.com
Subject: Re: [pve-devel] [PATCH pve-network v6 1/2] ipam: add Nautobot plugin
Date: Fri, 28 Nov 2025 11:36:38 +0100	[thread overview]
Message-ID: <7ffb5f7b-b8ce-42de-b5a4-3da190750ad2@proxmox.com> (raw)
In-Reply-To: <20250922091942.4715-2-lou.lecrivain@orange.fr>

some comments inline.

Mainly, there are some issues with some API calls no passing the
namespace parameter - so you cannot have the same prefix in different
namespaces.

On 9/22/25 11:19 AM, lou.lecrivain@orange.fr wrote:
> From: Lou Lecrivain <lou.lecrivain@wdz.de>
> 
> Nautobot is a network source of truth [0] and as such this plugin offers
> the possibility to automatically enter IP addresses and subnets
> (prefixes in Nautobot jargon) into Nautobot as soon as they are created
> in Proxmox VE.
> 
> Limitations:
> * Only Nautobot versions from v2.4.14 and onwards are supported,
>   previous versions didn't had support for IP range [1].
> * The IPAM plugin currently does not recognize whether VMs/CTs are
>   active/online and initially sets the status to Active but when the
>   VMs/CTs are switched off the status is not deactivated.
> * Nautobot has the possibility to map DNS names to IP addresses.
>   However, since we have no standardized way to set the DNS names in
>   Proxmox VE (for containers the container name in Proxmox VE is set as
>   the host name in the container, but this is not possible for VMs) we
>   refrain from setting a DNS name at the moment.
> * Nautobot does not have a field in IP address objects for a MAC address
>   or to mark them as gateway, so we write this in the comment.
> 
> [0] https://networktocode.com/nautobot/
> [1] https://github.com/nautobot/nautobot/releases/tag/v2.4.14
> 
> Co-authored-by: lou lecrivain <lou.lecrivain@wdz.de>
> Signed-off-by: Hannes Duerr <h.duerr@proxmox.com>
> Signed-off-by: Lou Lecrivain <lou.lecrivain@wdz.de>
> ---
>  src/PVE/API2/Network/SDN/Ipams.pm           |   1 +
>  src/PVE/Network/SDN/Ipams.pm                |   3 +
>  src/PVE/Network/SDN/Ipams/Makefile          |   2 +-
>  src/PVE/Network/SDN/Ipams/NautobotPlugin.pm | 460 ++++++++++++++++++++
>  4 files changed, 465 insertions(+), 1 deletion(-)
>  create mode 100644 src/PVE/Network/SDN/Ipams/NautobotPlugin.pm
> 
> diff --git a/src/PVE/API2/Network/SDN/Ipams.pm b/src/PVE/API2/Network/SDN/Ipams.pm
> index 9955817..4e76bce 100644
> --- a/src/PVE/API2/Network/SDN/Ipams.pm
> +++ b/src/PVE/API2/Network/SDN/Ipams.pm
> @@ -12,6 +12,7 @@ use PVE::Network::SDN::Ipams::Plugin;
>  use PVE::Network::SDN::Ipams::PVEPlugin;
>  use PVE::Network::SDN::Ipams::PhpIpamPlugin;
>  use PVE::Network::SDN::Ipams::NetboxPlugin;
> +use PVE::Network::SDN::Ipams::NautobotPlugin;
>  use PVE::Network::SDN::Dhcp;
>  use PVE::Network::SDN::Vnets;
>  use PVE::Network::SDN::Zones;
> diff --git a/src/PVE/Network/SDN/Ipams.pm b/src/PVE/Network/SDN/Ipams.pm
> index 00aa20c..ee36ab5 100644
> --- a/src/PVE/Network/SDN/Ipams.pm
> +++ b/src/PVE/Network/SDN/Ipams.pm
> @@ -12,11 +12,14 @@ use PVE::Network;
>  
>  use PVE::Network::SDN::Ipams::PVEPlugin;
>  use PVE::Network::SDN::Ipams::NetboxPlugin;
> +use PVE::Network::SDN::Ipams::NautobotPlugin;
>  use PVE::Network::SDN::Ipams::PhpIpamPlugin;
>  use PVE::Network::SDN::Ipams::Plugin;
>  
> +
>  PVE::Network::SDN::Ipams::PVEPlugin->register();
>  PVE::Network::SDN::Ipams::NetboxPlugin->register();
> +PVE::Network::SDN::Ipams::NautobotPlugin->register();
>  PVE::Network::SDN::Ipams::PhpIpamPlugin->register();
>  PVE::Network::SDN::Ipams::Plugin->init();
>  
> diff --git a/src/PVE/Network/SDN/Ipams/Makefile b/src/PVE/Network/SDN/Ipams/Makefile
> index 4e7d65f..75e5b9a 100644
> --- a/src/PVE/Network/SDN/Ipams/Makefile
> +++ b/src/PVE/Network/SDN/Ipams/Makefile
> @@ -1,4 +1,4 @@
> -SOURCES=Plugin.pm PhpIpamPlugin.pm NetboxPlugin.pm PVEPlugin.pm
> +SOURCES=Plugin.pm PhpIpamPlugin.pm NetboxPlugin.pm PVEPlugin.pm NautobotPlugin.pm
>  
>  
>  PERL5DIR=${DESTDIR}/usr/share/perl5
> diff --git a/src/PVE/Network/SDN/Ipams/NautobotPlugin.pm b/src/PVE/Network/SDN/Ipams/NautobotPlugin.pm
> new file mode 100644
> index 0000000..447bfaa
> --- /dev/null
> +++ b/src/PVE/Network/SDN/Ipams/NautobotPlugin.pm
> @@ -0,0 +1,460 @@
> +package PVE::Network::SDN::Ipams::NautobotPlugin;
> +
> +use strict;
> +use warnings;
> +use PVE::INotify;
> +use PVE::Cluster;
> +use PVE::Tools;
> +use NetAddr::IP;
> +use Net::Subnet qw(subnet_matcher);
> +use version 0.77;
> +
> +use base('PVE::Network::SDN::Ipams::Plugin');
> +
> +our $MINIMAL_NAUTOBOT_VERSION = version->declare('v2.4.14');
> +
> +sub type {
> +    return 'nautobot';
> +}
> +
> +sub properties {
> +    return {
> +        namespace => {
> +            type => 'string',
> +        },
> +    };
> +}
> +
> +sub options {
> +    return {
> +        url => { optional => 0 },
> +        token => { optional => 0 },
> +        namespace => { optional => 0 },
> +        fingerprint => { optional => 1 },
> +    };
> +}
> +
> +sub default_ip_status {
> +    return 'Active';
> +}
> +

could be a constant instead?

> +sub nautobot_api_request {
> +    my ($config, $method, $path, $params) = @_;
> +
> +    return PVE::Network::SDN::api_request(
> +        $method,
> +        "$config->{url}${path}",
> +        [
> +            'Content-Type' => 'application/json; charset=UTF-8',
> +            'Authorization' => "token $config->{token}",
> +            'Accept' => "application/json",
> +        ],
> +        $params,
> +        $config->{fingerprint},
> +    );
> +}
> +
> +sub add_subnet {
> +    my ($class, $config, undef, $subnet, $noerr) = @_;
> +
> +    my $cidr = $subnet->{cidr};
> +    my $namespace = $config->{namespace};
> +
> +    my $internalid = get_prefix_id($config, $cidr, $noerr);
> +    if ($internalid) {
> +        return if $noerr;
> +        die "could not add the subnet $subnet because it already exists in nautobot\n";

$subnet is a HASH reference, so printing it will just print the
reference - netbox uses $cidr for printing instead

> +    }
> +
> +    my $params = {
> +        prefix => $cidr,
> +        namespace => $namespace,
> +        status => default_ip_status(),
> +    };
> +
> +    eval { nautobot_api_request($config, "POST", "/ipam/prefixes/", $params); };
> +    if ($@) {
> +        return if $noerr;
> +        die "error adding the subnet $subnet to nautobot $@\n";

here too

> +    }
> +}
> +
> +sub update_subnet {
> +    my ($class, $plugin_config, $subnetid, $subnet, $old_subnet, $noerr) = @_;
> +    # dhcp ranges are not supported in nautobot so we don't have to update them
> +}
> +
> +sub del_subnet {
> +    my ($class, $config, $subnetid, $subnet, $noerr) = @_;
> +
> +    my $cidr = $subnet->{cidr};
> +
> +    my $internalid = get_prefix_id($config, $cidr, $noerr);
> +    if (!$internalid) {
> +        warn("could not find delete the subnet $cidr because it does not exist in nautobot\n");

nit: small wording issue

> +        return;
> +    }
> +
> +    if (!subnet_is_deletable($config, $subnetid, $subnet, $internalid, $noerr)) {
> +        return if $noerr;
> +        die "could not delete the subnet $cidr, it still contains ip addresses!\n";
> +    }
> +
> +    # delete associated gateway IP addresses
> +    $class->empty_subnet($config, $subnetid, $subnet, $internalid, $noerr);
> +
> +    eval { nautobot_api_request($config, "DELETE", "/ipam/prefixes/$internalid/"); };
> +    if ($@) {
> +        return if $noerr;
> +        die "error deleting subnet from nautobot: $@\n";
> +    }
> +    return 1;
> +}
> +
> +sub add_ip {
> +    my ($class, $config, undef, $subnet, $ip, $hostname, $mac, undef, $is_gateway, $noerr) = @_;
> +
> +    my $mask = $subnet->{mask};
> +    my $namespace = $config->{namespace};
> +
> +    my $description = undef;
> +    if ($is_gateway) {
> +        $description = 'gateway';
> +    } elsif ($mac) {
> +        $description = "mac:$mac";
> +    }
> +
> +    my $params = {
> +        address => "$ip/$mask",
> +        type => "dhcp",
> +        description => $description,
> +        namespace => $namespace,
> +        status => default_ip_status(),
> +    };
> +
> +    eval { nautobot_api_request($config, "POST", "/ipam/ip-addresses/", $params); };
> +
> +    if ($@) {
> +        if ($is_gateway) {
> +            die "error add subnet ip to ipam: ip $ip already exist: $@"
> +                if !is_ip_gateway($config, $ip, $noerr);
> +        } elsif (!$noerr) {
> +            die "error add subnet ip to ipam: ip already exist: $@";
> +        }
> +    }
> +}
> +
> +sub add_next_freeip {
> +    my ($class, $config, undef, $subnet, $hostname, $mac, undef, $noerr) = @_;
> +
> +    my $cidr = $subnet->{cidr};
> +    my $namespace = $config->{namespace};
> +
> +    my $internalid = get_prefix_id($config, $cidr, $noerr);
> +    if (!defined($internalid)) {
> +        return if $noerr;
> +        die "could not find prefix $cidr in nautobot\n";
> +    }
> +
> +    my $description = undef;
> +    $description = "mac:$mac" if $mac;
> +
> +    my $params = {
> +        type => "dhcp",
> +        description => $description,
> +        namespace => $namespace,
> +        status => default_ip_status(),
> +    };
> +
> +    my $response = eval {
> +        return nautobot_api_request(
> +            $config, "POST", "/ipam/prefixes/$internalid/available-ips/", $params,
> +        );
> +    };
> +    if ($@ || !$response) {
> +        return if $noerr;
> +        die "could not allocate ip in subnet $cidr: $@\n";
> +    }
> +
> +    my $ip = NetAddr::IP->new($response->{address});
> +
> +    return $ip->addr;
> +}
> +
> +sub add_range_next_freeip {
> +    my ($class, $config, $subnet, $range, $data, $noerr) = @_;
> +
> +    my $cidr = $subnet->{cidr};
> +    my $namespace = $config->{namespace};
> +
> +    my $internalid = get_prefix_id($config, $cidr, $noerr);
> +    if (!defined($internalid)) {
> +        return if $noerr;
> +        die "could not find prefix $cidr in nautobot\n";
> +    }
> +
> +    my $description = undef;
> +    $description = "mac:$data->{mac}" if $data->{mac};
> +
> +    my $params = {
> +        type => "dhcp",
> +        description => $description,
> +        namespace => $namespace,
> +        status => default_ip_status(),
> +    };
> +
> +    my $range_start = $range->{'start-address'};
> +    my $range_end = $range->{'end-address'};
> +
> +    my $response = eval {
> +        return nautobot_api_request(
> +            $config,
> +            "POST",
> +            "/ipam/prefixes/$internalid/available-ips/"
> +                . "?range_start=$range_start&range_end=$range_end",
> +            $params,
> +        );
> +    };
> +    if ($@ || !$response) {
> +        return if $noerr;
> +        die "could not allocate ip in subnet $cidr: $@\n";
> +    }
> +
> +    my $ip = NetAddr::IP->new($response->{address});
> +
> +    return $ip->addr;
> +}
> +
> +sub update_ip {
> +    my ($class, $config, $subnetid, $subnet, $ip, $hostname, $mac, undef, $is_gateway, $noerr) = @_;
> +
> +    my $mask = $subnet->{mask};
> +    my $namespace = $config->{namespace};
> +
> +    my $description = undef;
> +    if ($is_gateway) {
> +        $description = 'gateway';
> +    } elsif ($mac) {
> +        $description = "mac:$mac";
> +    }
> +
> +    my $params = {
> +        address => "$ip/$mask",
> +        type => "dhcp",
> +        description => $description,
> +        namespace => $namespace,
> +        status => default_ip_status(),
> +    };
> +
> +    my $ip_id = get_ip_id($config, $ip, $noerr);
> +    if (!defined($ip_id)) {
> +        return if $noerr;
> +        die "could not find the ip $ip in nautobot\n";
> +    }
> +
> +    eval { nautobot_api_request($config, "PATCH", "/ipam/ip-addresses/$ip_id/", $params); };
> +    if ($@) {
> +        return if $noerr;
> +        die "error updating ip $ip: $@";
> +    }
> +}
> +
> +sub del_ip {
> +    my ($class, $config, undef, undef, $ip, $noerr) = @_;
> +
> +    return if !$ip;
> +
> +    my $ip_id = get_ip_id($config, $ip, $noerr);
> +    if (!defined($ip_id)) {
> +        warn("could not find the ip $ip in nautobot\n");
> +        return;
> +    }
> +
> +    eval { nautobot_api_request($config, "DELETE", "/ipam/ip-addresses/$ip_id/"); };
> +    if ($@) {
> +        return if $noerr;
> +        die "error deleting ip $ip : $@\n";
> +    }
> +
> +    return 1;
> +}
> +
> +sub empty_subnet {
> +    my ($class, $config, $subnetid, $subnet, $subnetuuid, $noerr) = @_;
> +
> +    my $namespace = $config->{namespace};
> +
> +    my $response = eval {
> +        return nautobot_api_request(
> +            $config,
> +            "GET",
> +            "/ipam/ip-addresses/?namespace=$namespace&parent=$subnetuuid",
> +        );
> +    };
> +    if ($@) {
> +        return if $noerr;
> +        die "could not find the subnet $subnet in nautobot: $@\n";
> +    }
> +
> +    for my $ip (@{ $response->{results} }) {
> +        del_ip($class, $config, undef, undef, $ip->{host}, $noerr);
> +    }
> +
> +    return 1;
> +}
> +
> +sub subnet_is_deletable {
> +    my ($config, $subnetid, $subnet, $subnetuuid, $noerr) = @_;
> +
> +    my $namespace = $config->{namespace};
> +
> +    my $response = eval {
> +        return nautobot_api_request(
> +            $config,
> +            "GET",
> +            "/ipam/ip-addresses/?namespace=$namespace&parent=$subnetuuid",
> +        );
> +    };
> +    if ($@) {
> +        return if $noerr;
> +        die "error querying prefix $subnet: $@\n";
> +    }
> +    my $n_ips = scalar $response->{results}->@*;
> +
> +    # least costly check operation 1st
> +    return 1 if ($n_ips == 0);
> +
> +    for my $ip (values $response->{results}->@*) {
> +        if (!is_ip_gateway($config, $ip->{host}, $noerr)) {
> +            # some remaining IP is not a gateway so we can't delete the subnet
> +            return 0;
> +        }
> +    }
> +    #all remaining IPs are gateways
> +    return 1;
> +}
> +
> +sub verify_api {
> +    my ($class, $config) = @_;
> +
> +    my $namespace = $config->{namespace};
> +
> +    # check if the namespace and the status "Active" exist
> +    eval {
> +        my $status = nautobot_api_request($config, "GET", "/status/");
> +        my $current_version = version->parse($status->{'nautobot-version'});
> +        $current_version >= $MINIMAL_NAUTOBOT_VERSION
> +            || die "version $current_version is below minimal required Nautobot version "
> +            . "$MINIMAL_NAUTOBOT_VERSION";

might be better written as post-if as is usual in our codebase, i.e."

die "version $current_version is below minimal required Nautobot version"
  if $current_version < $MINIMAL_NAUTOBOT_VERSION;

> +
> +        get_namespace_id($config, $namespace) // die "namespace $namespace does not exist";
> +        get_status_id($config, default_ip_status())
> +            // die "the status " . default_ip_status() . " does not exist";
> +
> +    };
> +    if ($@) {
> +        die "could not use nautobot api: $@\n";
> +    }
> +}

could also be better as post-if - also might make sense to explicitly
return a bool here in case the check succeds - or undef if it

> +
> +sub get_ips_from_mac {
> +    my ($class, $config, $mac, $zone) = @_;
> +
> +    my $ip4 = undef;
> +    my $ip6 = undef;
> +
> +    my $data = eval { nautobot_api_request($config, "GET", "/ipam/ip-addresses/?q=$mac"); };

This needs to respect namespaces

> +    if ($@) {
> +        die "could not query ip address entry for mac $mac: $@";
> +    }
> +
> +    for my $ip (@{ $data->{results} }) {
> +        if ($ip->{ip_version} == 4 && !$ip4) {
> +            ($ip4, undef) = split(/\//, $ip->{address});
> +        }
> +
> +        if ($ip->{ip_version} == 6 && !$ip6) {
> +            ($ip6, undef) = split(/\//, $ip->{address});
> +        }
> +    }
> +
> +    return ($ip4, $ip6);
> +}
> +
> +sub on_update_hook {
> +    my ($class, $config) = @_;
> +
> +    PVE::Network::SDN::Ipams::NautobotPlugin::verify_api($class, $config);
> +}
> +
> +sub get_ip_id {
> +    my ($config, $ip, $noerr) = @_;
> +
> +    my $result =
> +        eval { return nautobot_api_request($config, "GET", "/ipam/ip-addresses/?address=$ip"); };

This needs to respect namespaces, otherwise it will look for IPs in all
namespaces

> +    if ($@) {
> +        return if $noerr;
> +        die "error while querying for ip $ip id: $@\n";
> +    }
> +
> +    my $data = @{ $result->{results} }[0];
> +    return $data->{id};
> +}
> +
> +sub get_prefix_id {
> +    my ($config, $cidr, $noerr) = @_;
> +
> +    my $result =
> +        eval { return nautobot_api_request($config, "GET", "/ipam/prefixes/?prefix=$cidr"); };

This needs to respect namespaces, otherwise it will look for prefixes in
all namespaces

> +    if ($@) {
> +        return if $noerr;
> +        die "error while querying for cidr $cidr prefix id: $@\n";
> +    }
> +
> +    my $data = @{ $result->{results} }[0];
> +    return $data->{id};
> +}
> +
> +sub get_namespace_id {
> +    my ($config, $namespace, $noerr) = @_;
> +
> +    my $result =
> +        eval { return nautobot_api_request($config, "GET", "/ipam/namespaces/?name=$namespace"); };
> +    if ($@) {
> +        return if $noerr;
> +        die "error while querying for namespace $namespace id: $@\n";
> +    }
> +
> +    my $data = @{ $result->{results} }[0];
> +    return $data->{id};
> +}
> +
> +sub get_status_id {
> +    my ($config, $status, $noerr) = @_;
> +
> +    my $result =
> +        eval { return nautobot_api_request($config, "GET", "/extras/statuses/?name=$status"); };
> +    if ($@) {
> +        return if $noerr;
> +        die "error while querying for status $status id: $@\n";
> +    }
> +
> +    my $data = @{ $result->{results} }[0];
> +    return $data->{id};
> +}
> +
> +sub is_ip_gateway {
> +    my ($config, $ip, $noerr) = @_;
> +
> +    my $result =
> +        eval { return nautobot_api_request($config, "GET", "/ipam/ip-addresses/?address=$ip"); };

This needs to respect namespaces, otherwise it will look for IPs in all
namespaces

> +    if ($@) {
> +        return if $noerr;
> +        die "error while checking if $ip is a gateway: $@\n";
> +    }
> +
> +    my $data = @{ $result->{results} }[0];
> +    return $data->{description} eq 'gateway';
> +}
> +
> +1;



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


      parent reply	other threads:[~2025-11-28 10:36 UTC|newest]

Thread overview: 5+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
     [not found] <20250922091942.4715-1-lou.lecrivain@orange.fr>
2025-09-22  9:19 ` Lou Lecrivain via pve-devel
2025-09-22  9:19 ` [pve-devel] [PATCH pve-network v6 2/2] ipam: add test cases for nautobot plugin Lou Lecrivain via pve-devel
2025-09-22  9:19 ` [pve-devel] [PATCH pve-docs v6] add documentation for nautobot ipam plugin Lou Lecrivain via pve-devel
2025-09-22  9:19 ` [pve-devel] [PATCH pve-manager v6] ipam: add UI dialog " Lou Lecrivain via pve-devel
     [not found] ` <20250922091942.4715-2-lou.lecrivain@orange.fr>
2025-11-28 10:36   ` Stefan Hanreich [this message]

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=7ffb5f7b-b8ce-42de-b5a4-3da190750ad2@proxmox.com \
    --to=s.hanreich@proxmox.com \
    --cc=lou.lecrivain@orange.fr \
    --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