From: "Rémi PAETA" <remi.paeta@comaite.com>
To: pve-devel@lists.proxmox.com
Subject: [RFC PATCH network 05/16] sdn: ovn: configure the chassis on boot and on network reload
Date: Tue, 13 Oct 2026 14:14:21 +0200 [thread overview]
Message-ID: <20261013121432.2642979-6-remi.paeta@comaite.com> (raw)
In-Reply-To: <20261013121432.2642979-1-remi.paeta@comaite.com>
ovn-controller needs the southbound database, the encapsulation type
and the local tunnel IP, which are stored in the external_ids of the
local Open_vSwitch table. They are now set whenever the network
configuration of a node gets generated, using the local IP of the route
towards the southbound database as tunnel IP. A chassis attached by PVE
gets detached again once the node is not part of any OVN zone anymore.
A node can only be attached to a single OVN deployment.
pvenetcommit.service removes the Open vSwitch database on every boot,
which drops these settings. The new pve-sdn-ovn-chassis.service sets
them again before the guests get started. The postinst needs the
debhelper token for the unit to get enabled.
ovn-controller needs a few seconds to connect to the southbound
database, so wait for up to ten seconds before warning that it is not
connected. The zone status also reports an ovn-controller that is not
connected.
Signed-off-by: Rémi PAETA <remi.paeta@comaite.com>
---
debian/libpve-network-perl.install | 1 +
debian/libpve-network-perl.postinst | 2 +
src/PVE/Network/SDN.pm | 19 +++
src/PVE/Network/SDN/Ovn.pm | 179 +++++++++++++++++++++++
src/PVE/Network/SDN/Zones/OvnPlugin.pm | 2 +
src/services/Makefile | 1 +
src/services/pve-sdn-ovn-chassis.service | 16 ++
src/test/run_test_ovn.pl | 145 ++++++++++++++++++
8 files changed, 365 insertions(+)
create mode 100644 src/services/pve-sdn-ovn-chassis.service
diff --git a/debian/libpve-network-perl.install b/debian/libpve-network-perl.install
index 4e63c1f..ad5fc2d 100644
--- a/debian/libpve-network-perl.install
+++ b/debian/libpve-network-perl.install
@@ -1,2 +1,3 @@
lib/systemd/system/dnsmasq@.service.d/00-dnsmasq-after-networking.conf /usr/lib/systemd/system/dnsmasq@.service.d/
+lib/systemd/system/pve-sdn-ovn-chassis.service /usr/lib/systemd/system/
usr/share/perl5/PVE/Network
diff --git a/debian/libpve-network-perl.postinst b/debian/libpve-network-perl.postinst
index 99faedf..360863e 100644
--- a/debian/libpve-network-perl.postinst
+++ b/debian/libpve-network-perl.postinst
@@ -32,4 +32,6 @@ case "$1" in
;;
esac
+#DEBHELPER#
+
exit 0
diff --git a/src/PVE/Network/SDN.pm b/src/PVE/Network/SDN.pm
index d601cd0..e2cdf3d 100644
--- a/src/PVE/Network/SDN.pm
+++ b/src/PVE/Network/SDN.pm
@@ -421,12 +421,18 @@ sub write_raw_etc_network_config {
Generates the network configuration for all SDN plugins and writes it to the SDN
interfaces files (/etc/network/interfaces.d/sdn).
+Also configures the local OVN chassis, as this is called on every node when the
+SDN configuration gets applied.
+
=cut
sub generate_etc_network_config {
my $running_cfg = PVE::Network::SDN::running_config();
my $raw_config = PVE::Network::SDN::generate_raw_etc_network_config($running_cfg);
PVE::Network::SDN::write_raw_etc_network_config($raw_config);
+
+ eval { PVE::Network::SDN::Ovn::configure_chassis($running_cfg) };
+ log_warn("failed to configure the OVN chassis - $@") if $@;
}
=head3 generate_frr_raw_config(\%running_config, \%fabric_config)
@@ -519,6 +525,19 @@ sub sync_ovn_northbound {
PVE::Network::SDN::Ovn::sync_northbound($previous_running_config, $running_config);
}
+=head3 generate_ovn_chassis_config()
+
+Configures the local ovn-controller according to the OVN zones of this node.
+Called on boot by pve-sdn-ovn-chassis.service, as the Open vSwitch database
+gets recreated on every boot.
+
+=cut
+
+sub generate_ovn_chassis_config {
+ my $running_config = PVE::Network::SDN::running_config();
+ PVE::Network::SDN::Ovn::configure_chassis($running_config);
+}
+
sub encode_value {
my ($type, $key, $value) = @_;
diff --git a/src/PVE/Network/SDN/Ovn.pm b/src/PVE/Network/SDN/Ovn.pm
index 142477b..a3fbe44 100644
--- a/src/PVE/Network/SDN/Ovn.pm
+++ b/src/PVE/Network/SDN/Ovn.pm
@@ -5,12 +5,19 @@ use v5.36;
use JSON;
use PVE::Cluster;
+use PVE::INotify;
use PVE::Network;
+use PVE::RESTEnvironment qw(log_warn);
use PVE::Tools qw(run_command);
+use PVE::Network::SDN::Zones::Plugin;
+
our $NBCTL = '/usr/bin/ovn-nbctl';
our $VSCTL = '/usr/bin/ovs-vsctl';
+our $APPCTL = '/usr/bin/ovn-appctl';
my $NBCTL_TIMEOUT = 10;
+# seconds to wait for ovn-controller to connect to the southbound database
+our $CONNECT_TIMEOUT = 10;
our $INTEGRATION_BRIDGE = 'br-int';
@@ -22,6 +29,9 @@ my $EXT_ZONE = 'pve-sdn-zone';
my $EXT_VNET = 'pve-sdn-vnet';
my $EXT_VMID = 'pve-sdn-vmid';
+# chassis settings written to the local Open_vSwitch table
+my @CHASSIS_KEYS = ('ovn-remote', 'ovn-encap-type', 'ovn-encap-ip', $EXT_CONTROLLER);
+
=head3 ovn_controllers(\%running_cfg)
Returns a hash of all controllers of type C<ovn> in C<\%running_cfg>, keyed by
@@ -448,4 +458,173 @@ sub plug_port {
run_command([$VSCTL, 'set', 'Interface', $port, "external_ids:iface-id=$iface"]);
}
+=head3 local_controllers(\%running_cfg, $nodename)
+
+Returns the OVN controllers used by the zones that are active on C<$nodename>.
+
+=cut
+
+sub local_controllers {
+ my ($running_cfg, $nodename) = @_;
+
+ my $controllers = ovn_controllers($running_cfg);
+ my $zones = $running_cfg->{zones}->{ids} // {};
+
+ my $local = {};
+ for my $zone (values %$zones) {
+ next if $zone->{type} ne 'ovn';
+ next if $zone->{nodes} && !$zone->{nodes}->{$nodename};
+
+ my $controllerid = $zone->{controller};
+ $local->{$controllerid} = $controllers->{$controllerid}
+ if $controllerid && $controllers->{$controllerid};
+ }
+
+ return $local;
+}
+
+=head3 parse_remote_ip($remotes)
+
+Returns the IP address of the first entry of an OVN database connection string
+such as C<tcp:10.0.0.1:6642,tcp:[fd00::1]:6642>.
+
+=cut
+
+sub parse_remote_ip {
+ my ($remotes) = @_;
+
+ my ($first) = split(/,/, $remotes);
+ return $1 if $first =~ m/^[a-z]+:\[([^\]]+)\]:\d+$/;
+ return $1 if $first =~ m/^[a-z]+:([^:]+):\d+$/;
+
+ die "unable to parse OVN database connection '$first'\n";
+}
+
+=head3 chassis_settings($controllerid, $controller, $encap_ip)
+
+Returns the external_ids to set in the local Open_vSwitch table so that
+ovn-controller joins the OVN deployment of C<$controller>.
+
+=cut
+
+sub chassis_settings {
+ my ($controllerid, $controller, $encap_ip) = @_;
+
+ return {
+ 'ovn-remote' => $controller->{'sb-connection'},
+ 'ovn-encap-type' => $controller->{'encap-type'} // 'geneve',
+ 'ovn-encap-ip' => $encap_ip,
+ $EXT_CONTROLLER => $controllerid,
+ };
+}
+
+=head3 configure_chassis(\%running_cfg)
+
+Configures the local ovn-controller for the OVN controller used by the zones of
+this node, or detaches it if no zone uses OVN anymore. Called on every node when
+the SDN configuration gets applied.
+
+=cut
+
+sub configure_chassis {
+ my ($running_cfg) = @_;
+
+ my $nodename = PVE::INotify::nodename();
+ my $controllers = local_controllers($running_cfg, $nodename);
+ my @ids = sort keys %$controllers;
+
+ if (!-x $VSCTL) {
+ die "Open vSwitch is not installed, but node '$nodename' is part of an OVN zone\n"
+ if @ids;
+ return;
+ }
+
+ if (@ids > 1) {
+ die "node '$nodename' is part of zones using different OVN controllers (@ids), but a"
+ . " node can only be attached to one OVN deployment\n";
+ }
+
+ if (!@ids) {
+ # only detach a chassis that was attached by us
+ my $attached = '';
+ eval {
+ run_command(
+ [
+ $VSCTL,
+ '--if-exists',
+ 'get',
+ 'Open_vSwitch',
+ '.',
+ "external_ids:$EXT_CONTROLLER",
+ ],
+ outfunc => sub { $attached .= $_[0]; },
+ );
+ };
+ if ($attached) {
+ run_command([$VSCTL, 'remove', 'Open_vSwitch', '.', 'external_ids', @CHASSIS_KEYS]);
+ }
+ return;
+ }
+
+ my $id = $ids[0];
+ my $controller = $controllers->{$id};
+
+ my $remote_ip = parse_remote_ip($controller->{'sb-connection'});
+ my ($encap_ip) = PVE::Network::SDN::Zones::Plugin::get_local_route_ip($remote_ip);
+ die "unable to find the local IP to reach the OVN southbound database at $remote_ip\n"
+ if !$encap_ip;
+
+ my $settings = chassis_settings($id, $controller, $encap_ip);
+ run_command([
+ $VSCTL,
+ 'set',
+ 'Open_vSwitch',
+ '.',
+ map { "external_ids:$_=$settings->{$_}" } sort keys %$settings,
+ ]);
+
+ if (!wait_controller_connected($CONNECT_TIMEOUT)) {
+ log_warn(
+ "ovn-controller is not connected to the OVN southbound database: running guests keep"
+ . " their connectivity, but new guest interfaces and OVN configuration changes are"
+ . " only applied once it is",
+ );
+ }
+}
+
+# ovn-controller needs a few seconds to connect, especially after a restart
+sub wait_controller_connected {
+ my ($timeout) = @_;
+
+ for (my $i = 0; $i < $timeout; $i++) {
+ return 1 if controller_connected();
+ sleep(1);
+ }
+
+ return controller_connected();
+}
+
+=head3 controller_connected()
+
+Returns whether the local ovn-controller is connected to the southbound
+database.
+
+=cut
+
+sub controller_connected {
+ return 0 if !-x $APPCTL;
+
+ my $status = '';
+ eval {
+ run_command(
+ [$APPCTL, '-t', 'ovn-controller', 'connection-status'],
+ outfunc => sub { $status .= $_[0]; },
+ errfunc => sub { },
+ timeout => 5,
+ );
+ };
+
+ return $status eq 'connected';
+}
+
1;
diff --git a/src/PVE/Network/SDN/Zones/OvnPlugin.pm b/src/PVE/Network/SDN/Zones/OvnPlugin.pm
index 26557e7..fc15e20 100644
--- a/src/PVE/Network/SDN/Zones/OvnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/OvnPlugin.pm
@@ -66,6 +66,8 @@ sub status {
if (!-d "/sys/class/net/$PVE::Network::SDN::Ovn::INTEGRATION_BRIDGE") {
push @$err_msg, "missing $PVE::Network::SDN::Ovn::INTEGRATION_BRIDGE";
+ } elsif (!PVE::Network::SDN::Ovn::controller_connected()) {
+ push @$err_msg, "ovn-controller not connected to the southbound database";
}
return $err_msg;
diff --git a/src/services/Makefile b/src/services/Makefile
index 818c106..b6cd56d 100644
--- a/src/services/Makefile
+++ b/src/services/Makefile
@@ -8,6 +8,7 @@ install:
install -d $(SERVICEDIR)
install -d $(SERVICEDIR)/dnsmasq@.service.d
install -t $(SERVICEDIR)/dnsmasq@.service.d -m 0644 00-dnsmasq-after-networking.conf
+ install -t $(SERVICEDIR) -m 0644 pve-sdn-ovn-chassis.service
.PHONY: clean
clean:
diff --git a/src/services/pve-sdn-ovn-chassis.service b/src/services/pve-sdn-ovn-chassis.service
new file mode 100644
index 0000000..13775cc
--- /dev/null
+++ b/src/services/pve-sdn-ovn-chassis.service
@@ -0,0 +1,16 @@
+# The Open vSwitch database gets recreated on every boot (see
+# pvenetcommit.service), so the OVN chassis settings need to be set again
+# before the guests get started.
+[Unit]
+Description=Configure the OVN chassis of the Proxmox VE SDN
+Wants=pve-cluster.service
+After=pve-cluster.service ovs-vswitchd.service networking.service
+Before=pve-guests.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/perl -e 'use PVE::Network::SDN; PVE::Network::SDN::generate_ovn_chassis_config()'
+RemainAfterExit=yes
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/test/run_test_ovn.pl b/src/test/run_test_ovn.pl
index b12ca0b..16b7eef 100755
--- a/src/test/run_test_ovn.pl
+++ b/src/test/run_test_ovn.pl
@@ -347,6 +347,151 @@ eval {
};
like($@, qr/isolate-ports is not supported/, 'vnet_update_hook: isolate-ports refused');
+# chassis
+
+is(
+ PVE::Network::SDN::Ovn::parse_remote_ip('tcp:10.0.0.1:6642,tcp:10.0.0.2:6642'),
+ '10.0.0.1',
+ 'parse_remote_ip: ipv4',
+);
+is(
+ PVE::Network::SDN::Ovn::parse_remote_ip('tcp:[fd00::1]:6642'),
+ 'fd00::1',
+ 'parse_remote_ip: ipv6',
+);
+
+is_deeply(
+ [sort keys PVE::Network::SDN::Ovn::local_controllers($running_cfg, 'node1')->%*],
+ ['ovnctl'],
+ 'local_controllers: zone restricted to other nodes is ignored',
+);
+
+is_deeply(
+ PVE::Network::SDN::Ovn::chassis_settings('ovnctl', $controller, '192.168.0.10'),
+ {
+ 'ovn-remote' => $controller->{'sb-connection'},
+ 'ovn-encap-type' => 'geneve',
+ 'ovn-encap-ip' => '192.168.0.10',
+ 'pve-sdn-controller' => 'ovnctl',
+ },
+ 'chassis_settings',
+);
+
+my $vsctl_calls = [];
+my $attached = '';
+$PVE::Network::SDN::Ovn::VSCTL = '/bin/true';
+$ovn_module->mock(
+ run_command => sub {
+ my ($cmd, %param) = @_;
+ if (grep { $_ eq 'get' } @$cmd) {
+ $param{outfunc}->($attached) if $attached;
+ return;
+ }
+ push @$vsctl_calls, [$cmd->@[1 .. $#$cmd]];
+ },
+ controller_connected => sub { return 1 },
+);
+my $inotify_module = Test::MockModule->new('PVE::INotify');
+$inotify_module->mock(nodename => sub { return 'node1' });
+my $zones_plugin_module = Test::MockModule->new('PVE::Network::SDN::Zones::Plugin');
+$zones_plugin_module->mock(get_local_route_ip => sub { return ('192.168.0.10', 'vmbr0') });
+
+PVE::Network::SDN::Ovn::configure_chassis($running_cfg);
+is_deeply(
+ $vsctl_calls,
+ [[
+ 'set',
+ 'Open_vSwitch',
+ '.',
+ 'external_ids:ovn-encap-ip=192.168.0.10',
+ 'external_ids:ovn-encap-type=geneve',
+ "external_ids:ovn-remote=$controller->{'sb-connection'}",
+ 'external_ids:pve-sdn-controller=ovnctl',
+ ]],
+ 'configure_chassis: attach chassis',
+);
+
+$vsctl_calls = [];
+PVE::Network::SDN::Ovn::configure_chassis({});
+is_deeply($vsctl_calls, [], 'configure_chassis: leave chassis not attached by PVE alone');
+
+$attached = '"ovnctl"';
+PVE::Network::SDN::Ovn::configure_chassis({});
+is_deeply(
+ $vsctl_calls,
+ [[
+ 'remove',
+ 'Open_vSwitch',
+ '.',
+ 'external_ids',
+ 'ovn-remote',
+ 'ovn-encap-type',
+ 'ovn-encap-ip',
+ 'pve-sdn-controller',
+ ]],
+ 'configure_chassis: detach chassis',
+);
+
+my $two_controllers = {
+ controllers => { ids => { a => $controller, b => $controller } },
+ zones => {
+ ids => {
+ za => { type => 'ovn', controller => 'a' },
+ zb => { type => 'ovn', controller => 'b' },
+ },
+ },
+};
+eval { PVE::Network::SDN::Ovn::configure_chassis($two_controllers) };
+like(
+ $@,
+ qr/can only be attached to one OVN deployment/,
+ 'configure_chassis: one deployment per node',
+);
+
+# chassis configured when the SDN configuration gets applied
+
+my $sdn_module = Test::MockModule->new('PVE::Network::SDN');
+my $written;
+$sdn_module->mock(
+ running_config => sub { return $running_cfg },
+ generate_raw_etc_network_config => sub { return "raw" },
+ write_raw_etc_network_config => sub { $written = $_[0] },
+);
+my $chassis_cfg;
+$ovn_module->mock(configure_chassis => sub { $chassis_cfg = $_[0] });
+PVE::Network::SDN::generate_etc_network_config();
+is($written, 'raw', 'generate_etc_network_config: network config written');
+is($chassis_cfg, $running_cfg, 'generate_etc_network_config: chassis configured');
+
+$written = undef;
+$ovn_module->mock(configure_chassis => sub { die "ovs down\n" });
+my @warnings;
+$sdn_module->mock(log_warn => sub { push @warnings, $_[0] });
+eval { PVE::Network::SDN::generate_etc_network_config() };
+is($@, '', 'generate_etc_network_config: chassis failure does not abort');
+is($written, 'raw', 'generate_etc_network_config: network config written despite chassis failure');
+like(
+ join('', @warnings),
+ qr/failed to configure the OVN chassis - ovs down/,
+ 'generate_etc_network_config: chassis failure is logged',
+);
+$ovn_module->unmock('configure_chassis');
+$sdn_module->unmock_all();
+
+# ovn-controller connection
+
+my $connection_checks = 0;
+my $connected_after = 2;
+$ovn_module->mock(
+ controller_connected =>
+ sub { $connection_checks++; return $connection_checks >= $connected_after },
+);
+ok(PVE::Network::SDN::Ovn::wait_controller_connected(10), 'wait_controller_connected: connects');
+is($connection_checks, 2, 'wait_controller_connected: waits until connected');
+($connection_checks, $connected_after) = (0, 100);
+ok(!PVE::Network::SDN::Ovn::wait_controller_connected(1), 'wait_controller_connected: timeout');
+$ovn_module->mock(controller_connected => sub { return 1 });
+
# controller schema
ok(
--
2.47.3
next prev parent reply other threads:[~2026-10-13 12:33 UTC|newest]
Thread overview: 18+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-09 9:22 [RFC] sdn: OVN zone and controller plugins Rémi PAETA
2026-10-13 12:14 ` [RFC PATCH ve-rs/network/manager/docs 00/16] sdn: add " Rémi PAETA
2026-10-13 12:14 ` [RFC PATCH ve-rs 01/16] ve-config: sdn: add ovn zone type Rémi PAETA
2026-10-13 12:14 ` SPAM: [RFC PATCH network 02/16] sdn: controllers: add ovn plugin Rémi PAETA
2026-10-13 12:14 ` [RFC PATCH network 03/16] sdn: zones: " Rémi PAETA
2026-10-13 12:14 ` [RFC PATCH network 04/16] sdn: ovn: sync the northbound database on reload Rémi PAETA
2026-10-13 12:14 ` Rémi PAETA [this message]
2026-10-13 12:14 ` [RFC PATCH network 06/16] sdn: ovn: add port security Rémi PAETA
2026-10-13 12:14 ` [RFC PATCH network 07/16] sdn: ovn: allow overriding the port security per vnet Rémi PAETA
2026-10-13 12:14 ` [RFC PATCH network 08/16] sdn: subnets: refuse gateway, snat and dhcp on ovn zones Rémi PAETA
2026-10-13 12:14 ` [RFC PATCH network 09/16] sdn: ovn: support ssl connections to the databases Rémi PAETA
2026-10-13 12:14 ` [RFC PATCH network 10/16] sdn: ovn: bind guest ports to the chassis of their node Rémi PAETA
2026-10-13 12:14 ` [RFC PATCH manager 11/16] ui: sdn: controller selector: allow filtering by controller type Rémi PAETA
2026-10-13 12:14 ` [RFC PATCH manager 12/16] ui: sdn: add ovn controller Rémi PAETA
2026-10-13 12:14 ` [RFC PATCH manager 13/16] ui: sdn: add ovn zone Rémi PAETA
2026-10-13 12:14 ` [RFC PATCH manager 14/16] ui: sdn: vnet: add port security for ovn zones Rémi PAETA
2026-10-13 12:14 ` [RFC PATCH manager 15/16] ui: sdn: vnet: disable isolate ports " Rémi PAETA
2026-10-13 12:14 ` [RFC PATCH docs 16/16] sdn: document ovn zones and controllers Rémi PAETA
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=20261013121432.2642979-6-remi.paeta@comaite.com \
--to=remi.paeta@comaite.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