all lists on lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH access-control/cluster/common/manager/network/proxmox-widget-toolkit/qemu-server v2 00/10] fix #7294: pool: add SDN VNets as pool members
@ 2026-06-26 13:10 David Riley
  2026-06-26 13:10 ` [PATCH pve-manager v2 01/10] ui: replace var with let to match style guide for variable declaration David Riley
                   ` (9 more replies)
  0 siblings, 10 replies; 11+ messages in thread
From: David Riley @ 2026-06-26 13:10 UTC (permalink / raw)
  To: pve-devel

This series implements support for adding SDN VNets to resource pools,
resolving #7294 [0]. This series depends on the v4 'fix #7520: sdn:
prune orphaned ACLs and handle VNet migrations' [1].

It does not, however, add zones as pool members as requested in #7294.
Zones currently share ACL paths for managing the zone itself and
allocating VNets within it. This makes self-service VNet management
without also granting zone management (and its associated 
side-effects) difficult.

This patch series extends the pool section in the user.cfg and
introduces a new network property to the pool configuration which will
hold VNet entries:
* vnet/<zone>/<vnet>
* vnet/<zone>/<vnet>/<vlan>

The type prefix allows future extension to other network resource 
types.

To prevent potential data loss from overwriting newly added VNets, a
cluster-version check is added which ensures all nodes are running a
version that supports this feature. Note: The hardcoded version guard
should be updated to match the final target release when being
applied.

The existing version check helpers were moved from `qemu-server` to a
new module within `pve-cluster` to make them available for this
implementation, and any future developments that require gatekeeping.
Appropriate attribution has been included for the relocated code.
Please let me know if this organizational move aligns with current
design preferences or additional adjustments are needed.

---
Thanks @Gabriel, @Daniel K. and @Jakob for the feedback.

Differences from v1: 
- Access: Fix permissions propagation. 
  Pool ACL paths are setup without propagation, therefore checking
  /sdn/zones/<zone>/<vnet>/<tag> fails even if the user has the
  permission for the base path /sdn/zones/<zone>/<vnet>.
  To allow this, the roles of the base VNet path are looked up if
  the exact tagged path is not found in the pool (see patch 5/9).
- API: Add a unified property string format for VNets 
  (zone=<zone>,vnet=<vnet>,tag=<tag>]), ensuring that zone and vnet
  are strictly required and coupled.
- API CLI: Add typetext to the vnet format for better error messages
  in the CLI
- API: The registered format validation for VNets and Zones now also
  check the length.
- API: Add membership checks for network resources during add/delete
  operations (matching storage/VM behavior).
- Relocated version helpers from PVE::Cluster to PVE::Tools
- UI: Fix light mode. The icons are now light grey to match the
  storage and vm icons.
- Series is now based on v4 instead of v3 of [1]
- Minor refactors

[0] https://bugzilla.proxmox.com/show_bug.cgi?id=7294
[1] https://lore.proxmox.com/pve-devel/20260626105258.56914-1-d.riley@proxmox.com/


pve-manager:

David Riley (3):
  ui: replace var with let to match style guide for variable declaration
  fix #7294: api: pool: add SDN VNets as pool members
  fix #7294: ui: pool: add SDN VNets as pool members

 PVE/API2/Pool.pm                 | 135 +++++++++++++++++++++++++--
 www/css/ext6-pve.css             |  15 +++
 www/manager6/Utils.js            |   1 +
 www/manager6/grid/PoolMembers.js | 151 ++++++++++++++++++++++++++++---
 4 files changed, 282 insertions(+), 20 deletions(-)


proxmox-widget-toolkit:

David Riley (1):
  fix #7294: css: theme: add opacity override for pool VNet icon

 src/proxmox-dark/scss/other/_icons.scss | 12 ++++++++++++
 1 file changed, 12 insertions(+)


pve-access-control:

David Riley (1):
  fix #7294: acl: pool: add SDN VNets as pool members

 src/PVE/AccessControl.pm  | 93 ++++++++++++++++++++++++++++++++++++---
 src/PVE/RPCEnvironment.pm | 68 ++++++++++++++++++++++++++--
 src/test/parser_writer.pl | 53 ++++++++++++++++++----
 3 files changed, 198 insertions(+), 16 deletions(-)


pve-network:

David Riley (2):
  fix #7294: sdn: register api formats for zones and vnets
  fix #7294: sdn: vnet: update pool members on vnet migration and
    deletion

 src/PVE/Network/SDN.pm              | 15 +++++++++++++++
 src/PVE/Network/SDN/VnetPlugin.pm   | 25 ++++++++++++++++++++++---
 src/PVE/Network/SDN/Zones/Plugin.pm | 25 ++++++++++++++++++++++---
 3 files changed, 59 insertions(+), 6 deletions(-)


pve-common:

David Riley (1):
  tools: add helpers for version comparison

 src/PVE/Tools.pm | 41 +++++++++++++++++++++++++++++++++++++++++
 1 file changed, 41 insertions(+)


pve-cluster:

David Riley (1):
  fix #7294: cluster: helpers: add cluster-wide version assertion

 src/PVE/Cluster.pm | 43 +++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 41 insertions(+), 2 deletions(-)


qemu-server:

David Riley (1):
  fix #7294: helpers: use cluster-wide version helper

 src/PVE/QemuMigrate.pm        |  3 ++-
 src/PVE/QemuServer/Helpers.pm | 42 ++---------------------------------
 2 files changed, 4 insertions(+), 41 deletions(-)


Summary over all repositories:
  15 files changed, 637 insertions(+), 85 deletions(-)

-- 
Generated by murpp 0.11.0




^ permalink raw reply	[flat|nested] 11+ messages in thread

* [PATCH pve-manager v2 01/10] ui: replace var with let to match style guide for variable declaration
  2026-06-26 13:10 [PATCH access-control/cluster/common/manager/network/proxmox-widget-toolkit/qemu-server v2 00/10] fix #7294: pool: add SDN VNets as pool members David Riley
@ 2026-06-26 13:10 ` David Riley
  2026-06-26 13:10 ` [PATCH pve-manager v2 02/10] fix #7294: api: pool: add SDN VNets as pool members David Riley
                   ` (8 subsequent siblings)
  9 siblings, 0 replies; 11+ messages in thread
From: David Riley @ 2026-06-26 13:10 UTC (permalink / raw)
  To: pve-devel

No semantic change intended.

Link: https://pve.proxmox.com/wiki/Javascript_Style_Guide#Variables
Signed-off-by: David Riley <d.riley@proxmox.com>
---
 www/manager6/grid/PoolMembers.js | 28 ++++++++++++++--------------
 1 file changed, 14 insertions(+), 14 deletions(-)

diff --git a/www/manager6/grid/PoolMembers.js b/www/manager6/grid/PoolMembers.js
index b0c9ccb3..69f50e30 100644
--- a/www/manager6/grid/PoolMembers.js
+++ b/www/manager6/grid/PoolMembers.js
@@ -13,7 +13,7 @@ Ext.define('PVE.pool.AddVM', {
     },
 
     initComponent: function () {
-        var me = this;
+        let me = this;
 
         if (!me.pool) {
             throw 'no pool specified';
@@ -23,7 +23,7 @@ Ext.define('PVE.pool.AddVM', {
         me.method = 'PUT';
         me.extraRequestParams.poolid = me.pool;
 
-        var vmsField = Ext.create('Ext.form.field.Text', {
+        let vmsField = Ext.create('Ext.form.field.Text', {
             name: 'vms',
             hidden: true,
             allowBlank: false,
@@ -32,7 +32,7 @@ Ext.define('PVE.pool.AddVM', {
         let basicFilter = (data) =>
             (data.type === 'lxc' || data.type === 'qemu') && data.pool !== me.pool;
 
-        var vmStore = Ext.create('Ext.data.Store', {
+        let vmStore = Ext.create('Ext.data.Store', {
             model: 'PVEResources',
             sorters: [
                 {
@@ -43,7 +43,7 @@ Ext.define('PVE.pool.AddVM', {
             filters: [(item) => basicFilter(item.data)],
         });
 
-        var vmGrid = Ext.create('widget.grid', {
+        let vmGrid = Ext.create('widget.grid', {
             store: vmStore,
             border: true,
             height: 480,
@@ -53,7 +53,7 @@ Ext.define('PVE.pool.AddVM', {
                 mode: 'SIMPLE',
                 listeners: {
                     selectionchange: function (model, selected, opts) {
-                        var selectedVms = [];
+                        let selectedVms = [];
                         selected.forEach(function (vm) {
                             selectedVms.push(vm.data.vmid);
                         });
@@ -127,7 +127,7 @@ Ext.define('PVE.pool.AddStorage', {
     extend: 'Proxmox.window.Edit',
 
     initComponent: function () {
-        var me = this;
+        let me = this;
 
         if (!me.pool) {
             throw 'no pool specified';
@@ -166,7 +166,7 @@ Ext.define('PVE.grid.PoolMembers', {
     stateId: 'grid-pool-members',
 
     initComponent: function () {
-        var me = this;
+        let me = this;
 
         if (!me.pool) {
             throw 'no pool specified';
@@ -193,7 +193,7 @@ Ext.define('PVE.grid.PoolMembers', {
             ],
         });
 
-        var coldef = PVE.data.ResourceStore.defaultColumns().filter(
+        let coldef = PVE.data.ResourceStore.defaultColumns().filter(
             (c) => c.dataIndex !== 'tags' && c.dataIndex !== 'lock',
         );
 
@@ -201,9 +201,9 @@ Ext.define('PVE.grid.PoolMembers', {
             me.rstore.load();
         };
 
-        var sm = Ext.create('Ext.selection.RowModel', {});
+        let sm = Ext.create('Ext.selection.RowModel', {});
 
-        var remove_btn = new Proxmox.button.Button({
+        let remove_btn = new Proxmox.button.Button({
             text: gettext('Remove'),
             disabled: true,
             selModel: sm,
@@ -214,7 +214,7 @@ Ext.define('PVE.grid.PoolMembers', {
                 );
             },
             handler: function (btn, event, rec) {
-                var params = { delete: 1, poolid: me.pool };
+                let params = { delete: 1, poolid: me.pool };
                 if (rec.data.type === 'storage') {
                     params.storage = rec.data.storage;
                 } else if (
@@ -254,7 +254,7 @@ Ext.define('PVE.grid.PoolMembers', {
                                 text: gettext('Virtual Machine'),
                                 iconCls: 'fa fa-desktop',
                                 handler: function () {
-                                    var win = Ext.create('PVE.pool.AddVM', { pool: me.pool });
+                                    let win = Ext.create('PVE.pool.AddVM', { pool: me.pool });
                                     win.on('destroy', reload);
                                     win.show();
                                 },
@@ -263,7 +263,7 @@ Ext.define('PVE.grid.PoolMembers', {
                                 text: gettext('Storage'),
                                 iconCls: 'fa fa-hdd-o',
                                 handler: function () {
-                                    var win = Ext.create('PVE.pool.AddStorage', { pool: me.pool });
+                                    let win = Ext.create('PVE.pool.AddStorage', { pool: me.pool });
                                     win.on('destroy', reload);
                                     win.show();
                                 },
@@ -280,7 +280,7 @@ Ext.define('PVE.grid.PoolMembers', {
             listeners: {
                 itemcontextmenu: PVE.Utils.createCmdMenu,
                 itemdblclick: function (v, record) {
-                    var ws = me.up('pveStdWorkspace');
+                    let ws = me.up('pveStdWorkspace');
                     ws.selectById(record.data.id);
                 },
                 activate: reload,
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 11+ messages in thread

* [PATCH pve-manager v2 02/10] fix #7294: api: pool: add SDN VNets as pool members
  2026-06-26 13:10 [PATCH access-control/cluster/common/manager/network/proxmox-widget-toolkit/qemu-server v2 00/10] fix #7294: pool: add SDN VNets as pool members David Riley
  2026-06-26 13:10 ` [PATCH pve-manager v2 01/10] ui: replace var with let to match style guide for variable declaration David Riley
@ 2026-06-26 13:10 ` David Riley
  2026-06-26 13:10 ` [PATCH pve-manager v2 03/10] fix #7294: ui: " David Riley
                   ` (7 subsequent siblings)
  9 siblings, 0 replies; 11+ messages in thread
From: David Riley @ 2026-06-26 13:10 UTC (permalink / raw)
  To: pve-devel

Extend the pool API to accept SDN VNets and optional VLAN tags. Group
VNets under the new 'network' property type in the pool configuration.

Unlike VMs or containers which strictly belong to a single pool, VNets
are shared similar to storage. A single VNet can be assigned to
multiple pools simultaneously, allowing cross-team usage without
management conflicts.

Enforce a cluster-wide version check before allowing network
assignments. This prevents older nodes from accidentally overwriting
the newly structured pool configurations.

Link: https://bugzilla.proxmox.com/show_bug.cgi?id=7294
Signed-off-by: David Riley <d.riley@proxmox.com>
---
 PVE/API2/Pool.pm | 135 ++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 129 insertions(+), 6 deletions(-)

diff --git a/PVE/API2/Pool.pm b/PVE/API2/Pool.pm
index 63aff5bb..6bd99abb 100644
--- a/PVE/API2/Pool.pm
+++ b/PVE/API2/Pool.pm
@@ -4,10 +4,12 @@ use strict;
 use warnings;
 
 use PVE::AccessControl;
-use PVE::Cluster qw (cfs_read_file cfs_write_file);
+use PVE::Cluster qw (cfs_read_file cfs_write_file assert_min_cluster_version);
 use PVE::Exception qw(raise_param_exc);
 use PVE::INotify;
+use PVE::Network;
 use PVE::Storage;
+use PVE::Tools;
 
 use PVE::SafeSyslog;
 
@@ -16,6 +18,26 @@ use PVE::RESTHandler;
 
 use base qw(PVE::RESTHandler);
 
+my $pool_network_format = {
+    zone => {
+        description => 'SDN Zone',
+        type => 'string',
+        format => 'pve-sdn-zone-id',
+    },
+    vnet => {
+        description => 'VNet to add or remove from this pool.',
+        type => 'string',
+        format => 'pve-sdn-vnet-id',
+    },
+    tag => {
+        description => "Specify a VLAN tag",
+        optional => 1,
+        type => 'integer',
+        minimum => 1,
+        maximum => 4094,
+    },
+};
+
 __PACKAGE__->register_method({
     name => 'index',
     path => '',
@@ -36,7 +58,7 @@ __PACKAGE__->register_method({
             },
             type => {
                 type => 'string',
-                enum => ['qemu', 'lxc', 'storage'],
+                enum => ['qemu', 'lxc', 'storage', 'network'],
                 optional => 1,
                 requires => 'poolid',
             },
@@ -61,7 +83,7 @@ __PACKAGE__->register_method({
                         properties => {
                             type => {
                                 type => 'string',
-                                enum => ['qemu', 'lxc', 'openvz', 'storage'],
+                                enum => ['qemu', 'lxc', 'openvz', 'storage', 'network'],
                             },
                             id => {
                                 type => 'string',
@@ -135,6 +157,29 @@ __PACKAGE__->register_method({
                 }
             }
 
+            if (!defined($param->{type}) || $param->{type} eq 'network') {
+                if ($pool_config->{network}) {
+                    for my $net_key (sort keys $pool_config->{network}->%*) {
+                        my ($type, @path) = split('/', $net_key);
+
+                        if ($type eq 'vnet') {
+                            my ($zoneid, $vnet, $tag) = @path;
+
+                            my $description = "$vnet ($zoneid)";
+                            $description = "$vnet.$tag ($zoneid)" if defined($tag);
+
+                            push @$members,
+                                {
+                                    type => 'network',
+                                    'network-type' => $type,
+                                    id => $net_key,
+                                    text => $description,
+                                };
+                        }
+                    }
+                }
+            }
+
             my $pool_info = {
                 members => $members,
             };
@@ -243,6 +288,13 @@ __PACKAGE__->register_method({
                 format => 'pve-storage-id-list',
                 optional => 1,
             },
+            network => {
+                description => 'Network resource to add or remove from this pool.',
+                type => 'string',
+                typetext => 'zone=<zone>,vnet=<vnet>[,tag=<tag>]',
+                format => $pool_network_format,
+                optional => 1,
+            },
             'allow-move' => {
                 description => 'Allow adding a guest even if already in another pool.'
                     . ' The guest will be removed from its current pool and added to this one.',
@@ -295,6 +347,13 @@ __PACKAGE__->register_method({
                 format => 'pve-storage-id-list',
                 optional => 1,
             },
+            network => {
+                description => 'Network resource to add or remove from this pool.',
+                type => 'string',
+                typetext => 'zone=<zone>,vnet=<vnet>[,tag=<tag>]',
+                format => $pool_network_format,
+                optional => 1,
+            },
             'allow-move' => {
                 description => 'Allow adding a guest even if already in another pool.'
                     . ' The guest will be removed from its current pool and added to this one.',
@@ -304,7 +363,7 @@ __PACKAGE__->register_method({
             },
             delete => {
                 description =>
-                    'Remove the passed VMIDs and/or storage IDs instead of adding them.',
+                    'Remove the passed VMIDs, storage IDs and/or network resource instead of adding them.',
                 type => 'boolean',
                 optional => 1,
                 default => 0,
@@ -373,6 +432,62 @@ __PACKAGE__->register_method({
                     }
                 }
 
+                if (defined($param->{network})) {
+                    my $vnet_entry = PVE::JSONSchema::parse_property_string(
+                        $pool_network_format, $param->{network},
+                    );
+
+                    # gatekeep vnet as pool members
+                    PVE::Cluster::assert_min_cluster_version(9, 2, 3);
+
+                    my ($zone, $vnetid, $tag) = $vnet_entry->@{qw(zone vnet tag)};
+
+                    my $zones_cfg = PVE::Network::SDN::Zones::config();
+                    die "SDN Zone '$zone' does not exist\n" if !$zones_cfg->{ids}->{$zone};
+
+                    my $vnets_cfg = PVE::Network::SDN::Vnets::config();
+
+                    my $vnet_data = $vnets_cfg->{ids}->{$vnetid}
+                        or die "VNet '$vnetid' does not exist\n";
+
+                    my $vnet_zone = $vnet_data->{zone};
+                    if ($zone ne $vnet_zone) {
+                        die
+                            "VNet '$vnetid' does not belong to zone '$zone' (it belongs to '$vnet_zone')\n";
+                    }
+
+                    my $network_key = "vnet/$vnet_zone/$vnetid";
+
+                    if (defined($tag)) {
+                        if (!$vnet_data->{vlanaware}) {
+                            die
+                                "VNet '$vnetid' is not VLAN-aware, cannot assign a specific tag\n";
+                        }
+
+                        $network_key .= "/$tag";
+                    }
+
+                    $rpcenv->check_perm_modify(
+                        $authuser,
+                        "/sdn/zones/$vnet_zone/$vnetid",
+                        ['SDN.Allocate'],
+                    );
+
+                    if ($param->{delete}) {
+                        if (!$pool_config->{network}->{$network_key}) {
+                            die "Network resource '$network_key' is not a pool member\n";
+                        }
+
+                        delete $pool_config->{network}->{$network_key};
+                    } else {
+                        if ($pool_config->{network}->{$network_key}) {
+                            die "Network resource '$network_key' is already a pool member\n";
+                        }
+
+                        $pool_config->{network}->{$network_key} = 1;
+                    }
+                }
+
                 cfs_write_file("user.cfg", $usercfg);
             },
             "update pools failed",
@@ -400,7 +515,7 @@ __PACKAGE__->register_method({
             },
             type => {
                 type => 'string',
-                enum => ['qemu', 'lxc', 'storage'],
+                enum => ['qemu', 'lxc', 'storage', 'network'],
                 optional => 1,
             },
         },
@@ -421,7 +536,7 @@ __PACKAGE__->register_method({
                     properties => {
                         type => {
                             type => 'string',
-                            enum => ['qemu', 'lxc', 'openvz', 'storage'],
+                            enum => ['qemu', 'lxc', 'openvz', 'storage', 'network'],
                         },
                         id => {
                             type => 'string',
@@ -524,6 +639,14 @@ __PACKAGE__->register_method({
                     die "pool '$pool' is not empty (contains storage '$storeid')\n";
                 }
 
+                for my $netid (sort keys $pool_config->{network}->%*) {
+                    my ($type, $id) = split('/', $netid, 2);
+                    $type = 'network' if !defined($type);
+                    $id = $netid if !defined($id);
+
+                    die "pool '$pool' is not empty (contains $type '$id')\n";
+                }
+
                 delete($usercfg->{pools}->{$pool});
                 PVE::AccessControl::delete_pool_acl($pool, $usercfg);
 
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 11+ messages in thread

* [PATCH pve-manager v2 03/10] fix #7294: ui: pool: add SDN VNets as pool members
  2026-06-26 13:10 [PATCH access-control/cluster/common/manager/network/proxmox-widget-toolkit/qemu-server v2 00/10] fix #7294: pool: add SDN VNets as pool members David Riley
  2026-06-26 13:10 ` [PATCH pve-manager v2 01/10] ui: replace var with let to match style guide for variable declaration David Riley
  2026-06-26 13:10 ` [PATCH pve-manager v2 02/10] fix #7294: api: pool: add SDN VNets as pool members David Riley
@ 2026-06-26 13:10 ` David Riley
  2026-06-26 13:10 ` [PATCH proxmox-widget-toolkit v2 04/10] fix #7294: css: theme: add opacity override for pool VNet icon David Riley
                   ` (6 subsequent siblings)
  9 siblings, 0 replies; 11+ messages in thread
From: David Riley @ 2026-06-26 13:10 UTC (permalink / raw)
  To: pve-devel

Add user interface to manage SDN VNets inside resource pools.
In the dialog window users can select an SDN zone, pick an associated
VNet, and optionally specify a VLAN tag.

The VNet selector only shows VNets of the selected zone to prevent
invalid configurations.

Link: https://bugzilla.proxmox.com/show_bug.cgi?id=7294
Signed-off-by: David Riley <d.riley@proxmox.com>
---
 www/css/ext6-pve.css             |  15 ++++
 www/manager6/Utils.js            |   1 +
 www/manager6/grid/PoolMembers.js | 123 +++++++++++++++++++++++++++++++
 3 files changed, 139 insertions(+)

diff --git a/www/css/ext6-pve.css b/www/css/ext6-pve.css
index 5c37dd29..3c383dfa 100644
--- a/www/css/ext6-pve.css
+++ b/www/css/ext6-pve.css
@@ -464,6 +464,21 @@ div.right-aligned {
     content: " ";
 }
 
+.x-fa-pool-net:before {
+    width: 14px;
+    height: 14px;
+    position: absolute;
+    left: 1px;
+    top: 1px;
+    opacity: 0.45;
+}
+
+.x-fa-pool-net-grid:before {
+    left: 14px;
+    top: 6px;
+    opacity: 0.65;
+}
+
 .x-fa-treepanel:before {
     width: 16px;
     height: 24px;
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 040b5ae0..8f53dfb6 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -1322,6 +1322,7 @@ Ext.define('PVE.Utils', {
                 const networkTypeMapping = {
                     fabric: 'fa fa-road',
                     zone: 'fa fa-th',
+                    vnet: 'fa fa-network-wired x-fa-pool-net x-fa-pool-net-grid',
                 };
 
                 return networkTypeMapping[record['network-type']] ?? '';
diff --git a/www/manager6/grid/PoolMembers.js b/www/manager6/grid/PoolMembers.js
index 69f50e30..5aaa2b71 100644
--- a/www/manager6/grid/PoolMembers.js
+++ b/www/manager6/grid/PoolMembers.js
@@ -158,6 +158,108 @@ Ext.define('PVE.pool.AddStorage', {
     },
 });
 
+Ext.define('PVE.pool.AddVnet', {
+    extend: 'Proxmox.window.Edit',
+
+    viewModel: {
+        data: {
+            zone: '',
+        },
+        formulas: {
+            vnetDisabled: function (get) {
+                return !get('zone');
+            },
+        },
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        if (!me.pool) {
+            throw 'no pool specified';
+        }
+
+        me.isCreate = true;
+        me.isAdd = true;
+        me.url = '/pools/';
+        me.method = 'PUT';
+        me.extraRequestParams.poolid = me.pool;
+
+        Ext.apply(me, {
+            subject: gettext('VNet'),
+            width: 350,
+            items: [
+                {
+                    xtype: 'inputpanel',
+                    onGetValues: function (values) {
+                        let network = `zone=${values.zone},vnet=${values.vnet}`;
+
+                        if (values.tag) {
+                            network += `,tag=${values.tag}`;
+                        }
+
+                        delete values.zone;
+                        delete values.vnet;
+                        delete values.tag;
+
+                        values.network = network;
+
+                        return values;
+                    },
+                    items: [
+                        {
+                            xtype: 'pveSDNZoneSelector',
+                            fieldLabel: gettext('Zone'),
+                            name: 'zone',
+                            allowBlank: false,
+                            bind: {
+                                value: '{zone}',
+                            },
+                            listeners: {
+                                change: function (_f, _value) {
+                                    let vnetField = me.down('field[name=vnet]');
+                                    if (vnetField) {
+                                        vnetField.setValue('');
+                                    }
+                                },
+                            },
+                        },
+                        {
+                            xtype: 'pveSDNVnetSelector',
+                            fieldLabel: gettext('VNet'),
+                            name: 'vnet',
+                            allowBlank: false,
+                            bind: {
+                                disabled: '{vnetDisabled}',
+                            },
+                            listeners: {
+                                beforequery: function (_queryPlan) {
+                                    let zone = me.getViewModel().get('zone');
+                                    let store = this.getStore();
+                                    store.clearFilter();
+                                    store.filter('zone', zone);
+                                    return true;
+                                },
+                            },
+                        },
+                        {
+                            xtype: 'proxmoxintegerfield',
+                            name: 'tag',
+                            fieldLabel: gettext('VLAN Tag'),
+                            minValue: 1,
+                            maxValue: 4094,
+                            allowBlank: true,
+                            emptyText: gettext('All'),
+                        },
+                    ],
+                },
+            ],
+        });
+
+        me.callParent();
+    },
+});
+
 Ext.define('PVE.grid.PoolMembers', {
     extend: 'Ext.grid.GridPanel',
     alias: ['widget.pvePoolMembers'],
@@ -223,6 +325,18 @@ Ext.define('PVE.grid.PoolMembers', {
                     rec.data.type === 'openvz'
                 ) {
                     params.vms = rec.data.vmid;
+                } else if (rec.get('type') === 'network') {
+                    if (rec.get('network-type') === 'vnet') {
+                        let [_type, zone, vnet, tag] = rec.data.id.split('/');
+
+                        let network = `zone=${zone},vnet=${vnet}`;
+
+                        if (tag) {
+                            network += `,tag=${tag}`;
+                        }
+
+                        params.network = network;
+                    }
                 } else {
                     throw 'unknown resource type';
                 }
@@ -268,6 +382,15 @@ Ext.define('PVE.grid.PoolMembers', {
                                     win.show();
                                 },
                             },
+                            {
+                                text: gettext('VNet'),
+                                iconCls: 'fa fa-network-wired x-fa-pool-net',
+                                handler: function () {
+                                    let win = Ext.create('PVE.pool.AddVnet', { pool: me.pool });
+                                    win.on('destroy', reload);
+                                    win.show();
+                                },
+                            },
                         ],
                     }),
                 },
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 11+ messages in thread

* [PATCH proxmox-widget-toolkit v2 04/10] fix #7294: css: theme: add opacity override for pool VNet icon
  2026-06-26 13:10 [PATCH access-control/cluster/common/manager/network/proxmox-widget-toolkit/qemu-server v2 00/10] fix #7294: pool: add SDN VNets as pool members David Riley
                   ` (2 preceding siblings ...)
  2026-06-26 13:10 ` [PATCH pve-manager v2 03/10] fix #7294: ui: " David Riley
@ 2026-06-26 13:10 ` David Riley
  2026-06-26 13:10 ` [PATCH pve-access-control v2 05/10] fix #7294: acl: pool: add SDN VNets as pool members David Riley
                   ` (5 subsequent siblings)
  9 siblings, 0 replies; 11+ messages in thread
From: David Riley @ 2026-06-26 13:10 UTC (permalink / raw)
  To: pve-devel

Add opacity adjustments for the `fa-network-wired` icon used by pool
networks.

The light/base theme lowers the icons opacity to simulate a light grey
color that matches other configuration elements. This override
restores the opacity to 1 in the dark theme so the native white SVG
remains clearly visible against the dark background.

Signed-off-by: David Riley <d.riley@proxmox.com>
---
 src/proxmox-dark/scss/other/_icons.scss | 12 ++++++++++++
 1 file changed, 12 insertions(+)

diff --git a/src/proxmox-dark/scss/other/_icons.scss b/src/proxmox-dark/scss/other/_icons.scss
index ad08906..3712094 100644
--- a/src/proxmox-dark/scss/other/_icons.scss
+++ b/src/proxmox-dark/scss/other/_icons.scss
@@ -250,3 +250,15 @@
 .usage {
   background-color: $icon-color;
 }
+
+// fa-network-wired override for dark mode.
+// The base/light theme lowers the opacity to simulate a light grey 
+// color. This restores the opacity to 1 so the icon remains white and
+// clearly visible in the dark mode 
+.x-fa-pool-net:before {
+   opacity: 1;
+}
+
+.x-fa-pool-net-grid:before {
+   opacity: 1;
+}
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 11+ messages in thread

* [PATCH pve-access-control v2 05/10] fix #7294: acl: pool: add SDN VNets as pool members
  2026-06-26 13:10 [PATCH access-control/cluster/common/manager/network/proxmox-widget-toolkit/qemu-server v2 00/10] fix #7294: pool: add SDN VNets as pool members David Riley
                   ` (3 preceding siblings ...)
  2026-06-26 13:10 ` [PATCH proxmox-widget-toolkit v2 04/10] fix #7294: css: theme: add opacity override for pool VNet icon David Riley
@ 2026-06-26 13:10 ` David Riley
  2026-06-26 13:10 ` [PATCH pve-network v2 06/10] fix #7294: sdn: register api formats for zones and vnets David Riley
                   ` (4 subsequent siblings)
  9 siblings, 0 replies; 11+ messages in thread
From: David Riley @ 2026-06-26 13:10 UTC (permalink / raw)
  To: pve-devel

Extend the pool configuration to allow SDN VNets as pool members by
introducing a new 'network' property.

The property tracks entries using a type prefix for future expansion:
* vnet/<zone>/<vnet>
* vnet/<zone>/<vnet>/<vlan>

Adapt the path resolution for bridges to ensure pool configurations
are considered. This is necessary to allow users to assign a VNet to
a VM when they only have access to a specific VLAN tag. Note that if
a VLAN tag is present in the pool configuration, the user is
restricted to that specific tag and cannot assign the base VNet
untagged.

Update the parser_writer tests to verify serialization and parsing
of the updated configuration format.

Link: https://bugzilla.proxmox.com/show_bug.cgi?id=7294
Signed-off-by: David Riley <d.riley@proxmox.com>
---
 src/PVE/AccessControl.pm  | 93 ++++++++++++++++++++++++++++++++++++---
 src/PVE/RPCEnvironment.pm | 68 ++++++++++++++++++++++++++--
 src/test/parser_writer.pl | 53 ++++++++++++++++++----
 3 files changed, 198 insertions(+), 16 deletions(-)

diff --git a/src/PVE/AccessControl.pm b/src/PVE/AccessControl.pm
index 34e56b6..6217f10 100644
--- a/src/PVE/AccessControl.pm
+++ b/src/PVE/AccessControl.pm
@@ -1559,7 +1559,7 @@ sub parse_user_config {
                 warn "user config - ignore invalid path in acl '$pathtxt'\n";
             }
         } elsif ($et eq 'pool') {
-            my ($pool, $comment, $vmlist, $storelist) = @data;
+            my ($pool, $comment, $vmlist, $storelist, $networklist) = @data;
 
             if (!verify_poolname($pool, 1)) {
                 warn "user config - ignore pool '$pool' - invalid characters in pool name\n";
@@ -1567,7 +1567,7 @@ sub parse_user_config {
             }
 
             # make sure to add the pool (even if there are no members)
-            $cfg->{pools}->{$pool} = { vms => {}, storage => {}, pools => {} }
+            $cfg->{pools}->{$pool} = { vms => {}, storage => {}, pools => {}, network => {} }
                 if !$cfg->{pools}->{$pool};
 
             if ($pool =~ m!/!) {
@@ -1576,8 +1576,12 @@ sub parse_user_config {
                     # ensure nested pool info is correctly recorded
                     my $parent = $1;
                     $cfg->{pools}->{$curr}->{parent} = $parent;
-                    $cfg->{pools}->{$parent} = { vms => {}, storage => {}, pools => {} }
-                        if !$cfg->{pools}->{$parent};
+
+                    if (!$cfg->{pools}->{$parent}) {
+                        $cfg->{pools}->{$parent} =
+                            { vms => {}, storage => {}, pools => {}, network => {} };
+                    }
+
                     $cfg->{pools}->{$parent}->{pools}->{$curr} = 1;
                     $curr = $parent;
                 }
@@ -1610,6 +1614,18 @@ sub parse_user_config {
                 }
                 $cfg->{pools}->{$pool}->{storage}->{$storeid} = 1;
             }
+
+            for my $network (split_list($networklist)) {
+
+                if ($network !~ m/^[a-z0-9][a-z0-9\-_]*(?:\/[a-z0-9][a-z0-9\-_]*)*$/i) {
+                    warn "user config - ignore invalid sdn resource entry '$network' in pool"
+                        . " '$pool'\n";
+                    next;
+                }
+
+                $cfg->{pools}->{$pool}->{network}->{$network} = 1;
+            }
+
         } elsif ($et eq 'token') {
             my ($tokenid, $expire, $privsep, $comment) = @data;
 
@@ -1691,8 +1707,9 @@ sub write_user_config {
         my $d = $cfg->{pools}->{$pool};
         my $vmlist = join(',', sort keys %{ $d->{vms} });
         my $storelist = join(',', sort keys %{ $d->{storage} });
+        my $networklist = join(',', sort keys %{ $d->{network} });
         my $comment = $d->{comment} ? PVE::Tools::encode_text($d->{comment}) : '';
-        $data .= "pool:$pool:$comment:$vmlist:$storelist:\n";
+        $data .= "pool:$pool:$comment:$vmlist:$storelist:$networklist:\n";
     }
 
     $data .= "\n";
@@ -1974,6 +1991,72 @@ sub remove_vm_from_pool {
     lock_user_config($delVMfromPoolFn, "pool cleanup for VM $vmid failed");
 }
 
+sub remove_vnet_from_pool {
+    my ($zone, $vnet) = @_;
+    my $network_key = "vnet/$zone/$vnet";
+
+    my $del_vnet_from_pool_fn = sub {
+        my $usercfg = cfs_read_file("user.cfg");
+        return if !$usercfg->{pools};
+
+        my $modified = 0;
+        for my $pool (keys $usercfg->{pools}->%*) {
+            my $pool_cfg = $usercfg->{pools}->{$pool};
+            next if !$pool_cfg->{network};
+
+            for my $net_key (keys $pool_cfg->{network}->%*) {
+                if ($net_key eq $network_key || $net_key =~ m!^\Q$network_key\E/\d+$!) {
+                    delete $pool_cfg->{network}->{$net_key};
+                    $modified = 1;
+                }
+            }
+        }
+
+        if ($modified) {
+            cfs_write_file("user.cfg", $usercfg);
+        }
+    };
+
+    lock_user_config($del_vnet_from_pool_fn, "pool cleanup for VNet $vnet failed");
+}
+
+sub migrate_vnet_zone_in_pool {
+    my ($src_zone, $dest_zone, $vnet) = @_;
+
+    my $src_key = "vnet/$src_zone/$vnet";
+    my $dest_key = "vnet/$dest_zone/$vnet";
+
+    my $update_vnet_zone_fn = sub {
+        my $usercfg = cfs_read_file("user.cfg");
+        return if !$usercfg->{pools};
+
+        my $modified = 0;
+        for my $pool (keys $usercfg->{pools}->%*) {
+            my $pool_cfg = $usercfg->{pools}->{$pool};
+            next if !$pool_cfg->{network};
+
+            for my $net_key (keys $pool_cfg->{network}->%*) {
+                if ($net_key eq $src_key || $net_key =~ m!^\Q$src_key\E/(\d+)$!) {
+                    my $vlan = $1;
+                    delete $pool_cfg->{network}->{$net_key};
+
+                    my $target_key = $dest_key;
+                    $target_key .= "/$vlan" if defined($vlan);
+                    $pool_cfg->{network}->{$target_key} = 1;
+
+                    $modified = 1;
+                }
+            }
+        }
+
+        if ($modified) {
+            cfs_write_file("user.cfg", $usercfg);
+        }
+    };
+
+    lock_user_config($update_vnet_zone_fn, "pool update for VNet $vnet failed");
+}
+
 sub remove_sdn_resource_access {
     my ($paths) = @_; # [ ['zones', '<zone>'], ['zones', '<zone>', '<vnet>'] ]
 
diff --git a/src/PVE/RPCEnvironment.pm b/src/PVE/RPCEnvironment.pm
index 7591aa9..d9372a0 100644
--- a/src/PVE/RPCEnvironment.pm
+++ b/src/PVE/RPCEnvironment.pm
@@ -53,6 +53,21 @@ my $compile_acl_path = sub {
                     $data->{poolroles}->{"/storage/$storeid"}->{$role} = 1;
                 }
             }
+
+            for my $network_key (keys $d->{network}->%*) {
+                my ($type, @path) = split('/', $network_key);
+
+                if ($type eq 'vnet') {
+                    my ($zoneid, $vnetid, $vlan) = @path;
+
+                    my $acl_path = "/sdn/zones/$zoneid/$vnetid";
+                    $acl_path = "$acl_path/$vlan" if defined($vlan);
+
+                    for my $role (keys $pool_roles->%*) {
+                        $data->{poolroles}->{$acl_path}->{$role} = 1;
+                    }
+                }
+            }
         }
     }
 
@@ -63,15 +78,30 @@ my $compile_acl_path = sub {
     # means the role is set
     my $roles = PVE::AccessControl::roles($cfg, $user, $path);
 
+    my $poolroles_path = $data->{poolroles}->{$path};
+
+    # Pool ACL paths are setup without propagation, therefore checking
+    # /sdn/zones/<zone>/<vnet>/<tag> fails even if the user has the
+    # permission for the base path /sdn/zones/<zone>/<vnet>.
+    # To allow this, the roles of the base VNet path are looked up if
+    # the exact tagged path is not found in the pool.
+    if (!defined($poolroles_path) && $path =~ m|^/sdn/zones/[^/]+/[^/]+/\d+$|) {
+        my @parts = split('/', $path);
+        my $base_vnet_path = join('/', @parts[0 .. 4]); # remove tag
+
+        # Inherit the permissions of the base VNet path for this request
+        $poolroles_path = $data->{poolroles}->{$base_vnet_path};
+    }
+
     # apply roles inherited from pools
-    if ($data->{poolroles}->{$path}) {
+    if ($poolroles_path) {
         # NoAccess must not be trumped by pool ACLs
         if (!defined($roles->{NoAccess})) {
-            if ($data->{poolroles}->{$path}->{NoAccess}) {
+            if ($poolroles_path->{NoAccess}) {
                 # but pool ACL NoAccess trumps regular ACL
                 $roles = { 'NoAccess' => 0 };
             } else {
-                foreach my $role (keys %{ $data->{poolroles}->{$path} }) {
+                for my $role (keys $poolroles_path->%*) {
                     # only use role from pool ACL if regular ACL didn't already
                     # set it, and never set propagation for pool-derived ACLs
                     $roles->{$role} = 0 if !defined($roles->{$role});
@@ -219,6 +249,8 @@ sub compute_api_permission {
                     $res->{vms}->{$priv} = 1;
                 } elsif ($priv =~ m/^Datastore\./) {
                     $res->{storage}->{$priv} = 1;
+                } elsif ($priv =~ m/^SDN\./) {
+                    $res->{sdn}->{$priv} = 1;
                 } elsif ($priv eq 'Permissions.Modify') {
                     $res->{storage}->{$priv} = 1;
                     $res->{vms}->{$priv} = 1;
@@ -274,6 +306,17 @@ sub get_effective_permissions {
         foreach my $storeid (keys %{ $d->{storage} }) {
             $paths->{"/storage/$storeid"} = 1;
         }
+
+        for my $network_key (keys $d->{network}->%*) {
+            my ($type, @path) = split('/', $network_key);
+
+            if ($type eq 'vnet') {
+                my ($zoneid, $vnetid, $vlan) = @path;
+                my $vnet_path = "/sdn/zones/$zoneid/$vnetid";
+                $vnet_path .= "/$vlan" if defined($vlan);
+                $paths->{$vnet_path} = 1;
+            }
+        }
     }
 
     my $perms = {};
@@ -353,6 +396,25 @@ sub check_sdn_bridge {
         }
     }
 
+    # check access to VLANs via pools
+    for my $pool (keys $cfg->{pools}->%*) {
+        my $poolcfg = $cfg->{pools}->{$pool};
+        next if !$poolcfg->{network};
+
+        for my $network_key (keys $poolcfg->{network}->%*) {
+            my ($type, @path) = split('/', $network_key);
+
+            if (defined($type) && $type eq 'vnet') {
+                my ($zoneid, $vnetid, $vlan) = @path;
+
+                if ($zoneid eq $zone && $vnetid eq $bridge && defined($vlan)) {
+                    my $vlanpath = "/sdn/zones/$zoneid/$vnetid/$vlan";
+                    return 1 if $self->check_any($username, $vlanpath, $privs, 1);
+                }
+            }
+        }
+    }
+
     # repeat check, but fatal
     $self->check_any($username, $path, $privs, 0) if !$noerr;
 
diff --git a/src/test/parser_writer.pl b/src/test/parser_writer.pl
index ea2778e..a809e21 100755
--- a/src/test/parser_writer.pl
+++ b/src/test/parser_writer.pl
@@ -238,24 +238,35 @@ my $default_cfg = {
         vms => {},
         storage => {},
         pools => {},
+        network => {},
     },
     test_pool_members => {
         'id' => 'testpool',
         vms => { 123 => 1, 1234 => 1 },
         storage => { 'local' => 1, 'local-zfs' => 1 },
         pools => {},
+        network => { "vnet/zone1/vnet1" => 1, 'vnet/zone2/vnet2' => 1 },
     },
     test_pool_duplicate_vms => {
         'id' => 'test_duplicate_vms',
         vms => {},
         storage => {},
         pools => {},
+        network => {},
     },
     test_pool_duplicate_storages => {
         'id' => 'test_duplicate_storages',
         vms => {},
         storage => { 'local' => 1, 'local-zfs' => 1 },
         pools => {},
+        network => {},
+    },
+    test_pool_duplicate_networks => {
+        'id' => 'test_duplicate_networks',
+        vms => {},
+        storage => {},
+        pools => {},
+        network => { "vnet/zone1/vnet1" => 1, "vnet/zone2/vnet2" => 1 },
     },
     acl_simple_user => {
         'path' => '/',
@@ -431,12 +442,15 @@ my $default_raw = {
         'test_role_privs_invalid' => 'role:testrole:VM.Invalid,Datastore.Audit,VM.Allocate:',
     },
     pools => {
-        'test_pool_empty' => 'pool:testpool::::',
-        'test_pool_invalid' => 'pool:testpool::non-numeric:inval!d:',
-        'test_pool_members' => 'pool:testpool::123,1234:local,local-zfs:',
-        'test_pool_duplicate_vms' => 'pool:test_duplicate_vms::123,1234::',
-        'test_pool_duplicate_vms_expected' => 'pool:test_duplicate_vms::::',
-        'test_pool_duplicate_storages' => 'pool:test_duplicate_storages:::local,local-zfs:',
+        'test_pool_empty' => 'pool:testpool:::::',
+        'test_pool_invalid' => 'pool:testpool::non-numeric:inval!d::',
+        'test_pool_members' =>
+            'pool:testpool::123,1234:local,local-zfs:vnet/zone1/vnet1,vnet/zone2/vnet2:',
+        'test_pool_duplicate_vms' => 'pool:test_duplicate_vms::123,1234:::',
+        'test_pool_duplicate_vms_expected' => 'pool:test_duplicate_vms:::::',
+        'test_pool_duplicate_storages' => 'pool:test_duplicate_storages:::local,local-zfs::',
+        'test_pool_duplicate_networks' =>
+            'pool:test_duplicate_networks::::vnet/zone1/vnet1,vnet/zone2/vnet2:',
     },
     acl => {
         'acl_simple_user' => 'acl:1:/:test@pam:PVEVMAdmin:',
@@ -696,6 +710,7 @@ my $tests = [
                     $default_cfg->{test_pool_members},
                     $default_cfg->{test_pool_duplicate_vms},
                     $default_cfg->{test_pool_duplicate_storages},
+                    $default_cfg->{test_pool_duplicate_networks},
                 ],
             ),
             vms => default_pool_vms_with([$default_cfg->{test_pool_members}]),
@@ -705,15 +720,37 @@ my $tests = [
             . "\n\n\n"
             . $default_raw->{pools}->{'test_pool_members'} . "\n"
             . $default_raw->{pools}->{'test_pool_duplicate_vms'} . "\n"
-            . $default_raw->{pools}->{'test_pool_duplicate_storages'} . "\n",
+            . $default_raw->{pools}->{'test_pool_duplicate_storages'} . "\n"
+            . $default_raw->{pools}->{'test_pool_duplicate_networks'} . "\n",
         expected_raw => ""
             . $default_raw->{users}->{'root@pam'}
             . "\n\n\n"
+            . $default_raw->{pools}->{'test_pool_duplicate_networks'} . "\n"
             . $default_raw->{pools}->{'test_pool_duplicate_storages'} . "\n"
             . $default_raw->{pools}->{'test_pool_duplicate_vms_expected'} . "\n"
             . $default_raw->{pools}->{'test_pool_members'}
             . "\n\n\n",
     },
+    {
+        name => "pool_format_backward_compatibility",
+        config => {
+            acl_root => default_acls(),
+            users => default_users(),
+            roles => default_roles(),
+            pools => default_pools_with([$default_cfg->{test_pool_empty}]),
+        },
+        raw => ""
+            . $default_raw->{users}->{'root@pam'}
+            . "\n\n\n"
+            # old format: 4 colons
+            . "pool:testpool::::\n\n\n",
+        expected_raw => ""
+            . $default_raw->{users}->{'root@pam'}
+            . "\n\n\n"
+            # new format 5: colons
+            . $default_raw->{pools}->{'test_pool_empty'}
+            . "\n\n\n",
+    },
     {
         name => "acl_simple_user",
         config => {
@@ -1102,7 +1139,7 @@ my $tests = [
             . 'user:test@pam:0:0::::::' . "\n"
             . 'token:test@pam!test:0:0::' . "\n\n"
             . 'group:testgroup:::' . "\n\n"
-            . 'pool:testpool::::' . "\n\n"
+            . 'pool:testpool:::::' . "\n\n"
             . 'role:testrole::' . "\n\n",
     },
 ];
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 11+ messages in thread

* [PATCH pve-network v2 06/10] fix #7294: sdn: register api formats for zones and vnets
  2026-06-26 13:10 [PATCH access-control/cluster/common/manager/network/proxmox-widget-toolkit/qemu-server v2 00/10] fix #7294: pool: add SDN VNets as pool members David Riley
                   ` (4 preceding siblings ...)
  2026-06-26 13:10 ` [PATCH pve-access-control v2 05/10] fix #7294: acl: pool: add SDN VNets as pool members David Riley
@ 2026-06-26 13:10 ` David Riley
  2026-06-26 13:10 ` [PATCH pve-network v2 07/10] fix #7294: sdn: vnet: update pool members on vnet migration and deletion David Riley
                   ` (3 subsequent siblings)
  9 siblings, 0 replies; 11+ messages in thread
From: David Riley @ 2026-06-26 13:10 UTC (permalink / raw)
  To: pve-devel

Register JSONSchema format validators for both SDN zones and VNets,
enabling the pool members API to verify these network identifiers.

Link: https://bugzilla.proxmox.com/show_bug.cgi?id=7294
Signed-off-by: David Riley <d.riley@proxmox.com>
---
 src/PVE/Network/SDN/VnetPlugin.pm   | 25 ++++++++++++++++++++++---
 src/PVE/Network/SDN/Zones/Plugin.pm | 25 ++++++++++++++++++++++---
 2 files changed, 44 insertions(+), 6 deletions(-)

diff --git a/src/PVE/Network/SDN/VnetPlugin.pm b/src/PVE/Network/SDN/VnetPlugin.pm
index e041575..fbddc06 100644
--- a/src/PVE/Network/SDN/VnetPlugin.pm
+++ b/src/PVE/Network/SDN/VnetPlugin.pm
@@ -16,17 +16,36 @@ PVE::Cluster::cfs_register_file(
     sub { __PACKAGE__->write_config(@_); },
 );
 
+my $sdn_vnet_id_pattern = '[a-zA-Z][a-zA-Z0-9]*[a-zA-Z0-9]';
+my $vnet_min_length = 2;
+my $vnet_max_length = 8;
+
 PVE::JSONSchema::register_standard_option(
     'pve-sdn-vnet-id',
     {
         description => "The SDN vnet object identifier.",
         type => 'string',
-        pattern => '[a-zA-Z][a-zA-Z0-9]*[a-zA-Z0-9]',
-        minLength => 2,
-        maxLength => 8,
+        pattern => $sdn_vnet_id_pattern,
+        minLength => $vnet_min_length,
+        maxLength => $vnet_max_length,
     },
 );
 
+sub pve_verify_sdn_vnet_id {
+    my ($vnet, $noerr) = @_;
+
+    my $len = length($vnet);
+
+    if ($vnet !~ m/^$sdn_vnet_id_pattern$/ || $len < $vnet_min_length || $len > $vnet_max_length) {
+        return undef if $noerr;
+        die "invalid SDN VNet '$vnet', must be $vnet_min_length-$vnet_max_length characters"
+            . " long, start with a letter, and contain only alphanumeric characters\n";
+    }
+    return $vnet;
+}
+
+PVE::JSONSchema::register_format('pve-sdn-vnet-id', \&pve_verify_sdn_vnet_id);
+
 my $defaultData = {
 
     propertyList => {
diff --git a/src/PVE/Network/SDN/Zones/Plugin.pm b/src/PVE/Network/SDN/Zones/Plugin.pm
index 74a3384..81bcf86 100644
--- a/src/PVE/Network/SDN/Zones/Plugin.pm
+++ b/src/PVE/Network/SDN/Zones/Plugin.pm
@@ -19,17 +19,36 @@ PVE::Cluster::cfs_register_file(
     sub { __PACKAGE__->write_config(@_); },
 );
 
+my $sdn_zone_id_pattern = '[a-zA-Z][a-zA-Z0-9]*[a-zA-Z0-9]';
+my $zone_min_length = 2;
+my $zone_max_length = 8;
+
 PVE::JSONSchema::register_standard_option(
     'pve-sdn-zone-id',
     {
         description => "The SDN zone object identifier.",
         type => 'string',
-        pattern => '[a-zA-Z][a-zA-Z0-9]*[a-zA-Z0-9]',
-        minLength => 2,
-        maxLength => 8,
+        pattern => $sdn_zone_id_pattern,
+        minLength => $zone_min_length,
+        maxLength => $zone_max_length,
     },
 );
 
+sub pve_verify_sdn_zone_id {
+    my ($zone, $noerr) = @_;
+
+    my $len = length($zone);
+
+    if ($zone !~ m/^$sdn_zone_id_pattern$/ || $len < $zone_min_length || $len > $zone_max_length) {
+        return undef if $noerr;
+        die "invalid SDN zone '$zone', must be $zone_min_length-$zone_max_length characters long,"
+            . " start with a letter, and contain only alphanumeric characters\n";
+    }
+    return $zone;
+}
+
+PVE::JSONSchema::register_format('pve-sdn-zone-id', \&pve_verify_sdn_zone_id);
+
 my $defaultData = {
 
     propertyList => {
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 11+ messages in thread

* [PATCH pve-network v2 07/10] fix #7294: sdn: vnet: update pool members on vnet migration and deletion
  2026-06-26 13:10 [PATCH access-control/cluster/common/manager/network/proxmox-widget-toolkit/qemu-server v2 00/10] fix #7294: pool: add SDN VNets as pool members David Riley
                   ` (5 preceding siblings ...)
  2026-06-26 13:10 ` [PATCH pve-network v2 06/10] fix #7294: sdn: register api formats for zones and vnets David Riley
@ 2026-06-26 13:10 ` David Riley
  2026-06-26 13:10 ` [PATCH pve-common v2 08/10] tools: add helpers for version comparison David Riley
                   ` (2 subsequent siblings)
  9 siblings, 0 replies; 11+ messages in thread
From: David Riley @ 2026-06-26 13:10 UTC (permalink / raw)
  To: pve-devel

Update or remove corresponding resource pool allocations in user.cfg
whenever a VNet is altered or deleted. This prevents stale paths and
dangling references from breaking the configuration integrity.

Link: https://bugzilla.proxmox.com/show_bug.cgi?id=7294
Signed-off-by: David Riley <d.riley@proxmox.com>
---
 src/PVE/Network/SDN.pm | 15 +++++++++++++++
 1 file changed, 15 insertions(+)

diff --git a/src/PVE/Network/SDN.pm b/src/PVE/Network/SDN.pm
index cd563d2..81f5d9a 100644
--- a/src/PVE/Network/SDN.pm
+++ b/src/PVE/Network/SDN.pm
@@ -261,11 +261,26 @@ sub cleanup {
         PVE::AccessControl::migrate_sdn_resource_access($vnet_move_paths);
     }
 
+    for my $move (@$vnet_move_paths) {
+        my ($src_type, $src_zone, $vnet) = @{ $move->{src_path} };
+        my ($dest_type, $dest_zone, $dest_vnet) = @{ $move->{dest_path} };
+
+        if (defined($src_type) && $src_type eq 'zones' && $src_zone && $vnet && $dest_zone) {
+            PVE::AccessControl::migrate_vnet_zone_in_pool($src_zone, $dest_zone, $vnet);
+        }
+    }
+
     my @paths_to_delete = (@$vnet_delete_paths, @$route_map_paths, @$generic_paths);
     if (@paths_to_delete) {
         PVE::AccessControl::remove_sdn_resource_access(\@paths_to_delete);
     }
 
+    for my $path (@$vnet_delete_paths) {
+        my ($type, $zone, $vnet) = @$path;
+        if ($type && $type eq 'zones' && $zone && $vnet) {
+            PVE::AccessControl::remove_vnet_from_pool($zone, $vnet);
+        }
+    }
 }
 
 sub diff_generic_resources {
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 11+ messages in thread

* [PATCH pve-common v2 08/10] tools: add helpers for version comparison
  2026-06-26 13:10 [PATCH access-control/cluster/common/manager/network/proxmox-widget-toolkit/qemu-server v2 00/10] fix #7294: pool: add SDN VNets as pool members David Riley
                   ` (6 preceding siblings ...)
  2026-06-26 13:10 ` [PATCH pve-network v2 07/10] fix #7294: sdn: vnet: update pool members on vnet migration and deletion David Riley
@ 2026-06-26 13:10 ` David Riley
  2026-06-26 13:10 ` [PATCH pve-cluster v2 09/10] fix #7294: cluster: helpers: add cluster-wide version assertion David Riley
  2026-06-26 13:10 ` [PATCH qemu-server v2 10/10] fix #7294: helpers: use cluster-wide version helper David Riley
  9 siblings, 0 replies; 11+ messages in thread
From: David Riley @ 2026-06-26 13:10 UTC (permalink / raw)
  To: pve-devel

Move 'version_cmp' and 'pvecfg_min_version' over from qemu-server, to
make them more accessible.

Originally-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
Originally-by: Alexandre Derumier <aderumier@odiso.com>
Signed-off-by: David Riley <d.riley@proxmox.com>
---
 src/PVE/Tools.pm | 41 +++++++++++++++++++++++++++++++++++++++++
 1 file changed, 41 insertions(+)

diff --git a/src/PVE/Tools.pm b/src/PVE/Tools.pm
index 0513fe1..7e66546 100644
--- a/src/PVE/Tools.pm
+++ b/src/PVE/Tools.pm
@@ -54,6 +54,8 @@ our @EXPORT_OK = qw(
     extract_sensitive_params
     file_copy
     get_host_arch
+    pvecfg_min_version
+    version_cmp
     O_PATH
     O_CLOEXEC
     O_TMPFILE
@@ -1945,4 +1947,43 @@ sub is_deeply {
     return 1;
 }
 
+sub pvecfg_min_version {
+    my ($verstr, $major, $minor, $release) = @_;
+
+    return 0 if !$verstr;
+
+    if ($verstr =~ m/^(\d+)\.(\d+)(?:[.-](\d+))?/) {
+        return 1 if version_cmp($1, $major, $2, $minor, $3 // 0, $release) >= 0;
+        return 0;
+    }
+
+    die "internal error: cannot check version of invalid string '$verstr'";
+}
+
+# gets in pairs the versions you want to compares, i.e.:
+# ($a-major, $b-major, $a-minor, $b-minor, $a-extra, $b-extra, ...)
+# returns 0 if same, -1 if $a is older than $b, +1 if $a is newer than $b
+sub version_cmp {
+    my @versions = @_;
+
+    my $size = scalar(@versions);
+
+    return 0 if $size == 0;
+
+    if ($size & 1) {
+        my (undef, $fn, $line) = caller(0);
+        die "cannot compare odd count of versions, called from $fn:$line\n";
+    }
+
+    for (my $i = 0; $i < $size; $i += 2) {
+        my ($left, $right) = splice(@versions, 0, 2);
+        $left //= 0;
+        $right //= 0;
+
+        return 1 if $left > $right;
+        return -1 if $left < $right;
+    }
+    return 0;
+}
+
 1;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 11+ messages in thread

* [PATCH pve-cluster v2 09/10] fix #7294: cluster: helpers: add cluster-wide version assertion
  2026-06-26 13:10 [PATCH access-control/cluster/common/manager/network/proxmox-widget-toolkit/qemu-server v2 00/10] fix #7294: pool: add SDN VNets as pool members David Riley
                   ` (7 preceding siblings ...)
  2026-06-26 13:10 ` [PATCH pve-common v2 08/10] tools: add helpers for version comparison David Riley
@ 2026-06-26 13:10 ` David Riley
  2026-06-26 13:10 ` [PATCH qemu-server v2 10/10] fix #7294: helpers: use cluster-wide version helper David Riley
  9 siblings, 0 replies; 11+ messages in thread
From: David Riley @ 2026-06-26 13:10 UTC (permalink / raw)
  To: pve-devel

Add function to assert a minimum version requirement for the whole
cluster.

Evaluates against the cluster configuration. Dies if any node is
offline, or running an older version, ensuring cluster-wide feature
compatibility before allowing an operation to proceed.

Prevents older nodes from truncating newly structured configuration
entries.

Link: https://bugzilla.proxmox.com/show_bug.cgi?id=7294
Signed-off-by: David Riley <d.riley@proxmox.com>
---
 src/PVE/Cluster.pm | 43 +++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 41 insertions(+), 2 deletions(-)

diff --git a/src/PVE/Cluster.pm b/src/PVE/Cluster.pm
index 034b78c..3ecde03 100644
--- a/src/PVE/Cluster.pm
+++ b/src/PVE/Cluster.pm
@@ -20,7 +20,7 @@ use PVE::IPCC;
 use PVE::JSONSchema;
 use PVE::Network;
 use PVE::SafeSyslog;
-use PVE::Tools qw(run_command);
+use PVE::Tools qw(run_command pvecfg_min_version);
 
 use PVE::Cluster::IPCConst;
 
@@ -30,7 +30,9 @@ our @EXPORT_OK = qw(
     cfs_read_file
     cfs_write_file
     cfs_register_file
-    cfs_lock_file);
+    cfs_lock_file
+    assert_min_cluster_version
+);
 
 # x509 certificate utils
 
@@ -929,4 +931,41 @@ sub cfs_rename_db_unsafe {
     rename $dbfile, "$dbfile.$ctime.bak" or warn "failed to rename old config database - $!\n";
 }
 
+# Asserts that all configured nodes in the cluster meet a minimum version requirement.
+# Evaluates against the full, static cluster node list rather than only live nodes.
+# Aborts if any node is offline, unreadable, or running an
+# outdated version, preventing partial feature rollouts from corrupting cluster state.
+# NOTE: Uses PVE::Tools::pvecfg_min_version, which currently checks basic major.minor.release
+# triples. If gatekeeping a feature right around a major release transition, be aware that it
+# does not yet lexically sort trailing Debian revision suffixes (e.g., distinguishing '9.0.0' as
+# newer than '9.0.0~22').
+sub assert_min_cluster_version {
+    my ($major, $minor, $release) = @_;
+
+    my $nodestat = get_node_kv('version-info') || {};
+    my $nodelist = get_nodelist() || [];
+
+    for my $node (sort @$nodelist) {
+        my $raw_json = $nodestat->{$node};
+
+        if (!defined($raw_json)) {
+            die "cannot execute operation: cluster node '$node' is offline or not reporting its"
+                . " version. All nodes must be online to verify cluster-wide feature support.\n";
+        }
+
+        my $vinfo = eval { decode_json($raw_json) };
+        if ($@ || !$vinfo || !$vinfo->{version}) {
+            die "cannot execute operation: failed to parse cluster version string for node"
+                . " '$node'.\n";
+        }
+
+        if (!pvecfg_min_version($vinfo->{version}, $major, $minor, $release)) {
+            die "cannot execute operation: cluster node '$node' runs an older version of Proxmox"
+                . " VE ($vinfo->{version}) than the required $major.$minor.$release. Please"
+                . " upgrade all nodes first.\n";
+        }
+    }
+    return 1;
+}
+
 1;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 11+ messages in thread

* [PATCH qemu-server v2 10/10] fix #7294: helpers: use cluster-wide version helper
  2026-06-26 13:10 [PATCH access-control/cluster/common/manager/network/proxmox-widget-toolkit/qemu-server v2 00/10] fix #7294: pool: add SDN VNets as pool members David Riley
                   ` (8 preceding siblings ...)
  2026-06-26 13:10 ` [PATCH pve-cluster v2 09/10] fix #7294: cluster: helpers: add cluster-wide version assertion David Riley
@ 2026-06-26 13:10 ` David Riley
  9 siblings, 0 replies; 11+ messages in thread
From: David Riley @ 2026-06-26 13:10 UTC (permalink / raw)
  To: pve-devel

Drop the local `version_cmp` and `pvecfg_min_version` implementations
and update call sites to use the centralized tools module in
pve-common.

Link: https://bugzilla.proxmox.com/show_bug.cgi?id=7294
Signed-off-by: David Riley <d.riley@proxmox.com>
---
 src/PVE/QemuMigrate.pm        |  3 ++-
 src/PVE/QemuServer/Helpers.pm | 42 ++---------------------------------
 2 files changed, 4 insertions(+), 41 deletions(-)

diff --git a/src/PVE/QemuMigrate.pm b/src/PVE/QemuMigrate.pm
index 8da6f15d..65950a8f 100644
--- a/src/PVE/QemuMigrate.pm
+++ b/src/PVE/QemuMigrate.pm
@@ -23,6 +23,7 @@ use PVE::Storage::Plugin;
 use PVE::Storage;
 use PVE::StorageTunnel;
 use PVE::Tools;
+use PVE::Tools qw(pvecfg_min_version);
 use PVE::Tunnel;
 
 use PVE::QemuConfig;
@@ -1400,7 +1401,7 @@ sub phase2 {
         # Check if target is new enough for having the port encoded in the proxy ticket.
         if (
             $target_version
-            && PVE::QemuServer::Helpers::pvecfg_min_version($target_version, 9, 1, 9)
+            && PVE::Tools::pvecfg_min_version($target_version, 9, 1, 9)
         ) {
             $ticket_port = $spice_port;
         }
diff --git a/src/PVE/QemuServer/Helpers.pm b/src/PVE/QemuServer/Helpers.pm
index dd17eef5..c0db925b 100644
--- a/src/PVE/QemuServer/Helpers.pm
+++ b/src/PVE/QemuServer/Helpers.pm
@@ -11,6 +11,7 @@ use PVE::Cluster;
 use PVE::INotify;
 use PVE::ProcFSTools;
 use PVE::Tools;
+use PVE::Tools qw(version_cmp);
 
 use base 'Exporter';
 our @EXPORT_OK = qw(
@@ -228,39 +229,13 @@ sub min_version {
     my ($verstr, $major, $minor, $pve) = @_;
 
     if ($verstr =~ m/^(\d+)\.(\d+)(?:\.(\d+))?(?:\+pve(\d+))?/) {
-        return 1 if version_cmp($1, $major, $2, $minor, $4, $pve) >= 0;
+        return 1 if PVE::Tools::version_cmp($1, $major, $2, $minor, $4, $pve) >= 0;
         return 0;
     }
 
     die "internal error: cannot check version of invalid string '$verstr'";
 }
 
-# gets in pairs the versions you want to compares, i.e.:
-# ($a-major, $b-major, $a-minor, $b-minor, $a-extra, $b-extra, ...)
-# returns 0 if same, -1 if $a is older than $b, +1 if $a is newer than $b
-sub version_cmp {
-    my @versions = @_;
-
-    my $size = scalar(@versions);
-
-    return 0 if $size == 0;
-
-    if ($size & 1) {
-        my (undef, $fn, $line) = caller(0);
-        die "cannot compare odd count of versions, called from $fn:$line\n";
-    }
-
-    for (my $i = 0; $i < $size; $i += 2) {
-        my ($left, $right) = splice(@versions, 0, 2);
-        $left //= 0;
-        $right //= 0;
-
-        return 1 if $left > $right;
-        return -1 if $left < $right;
-    }
-    return 0;
-}
-
 sub config_aware_timeout {
     my ($config, $memory, $is_suspended) = @_;
     my $timeout = 30;
@@ -307,19 +282,6 @@ sub get_node_pvecfg_version {
     return $version_info->{version};
 }
 
-sub pvecfg_min_version {
-    my ($verstr, $major, $minor, $release) = @_;
-
-    return 0 if !$verstr;
-
-    if ($verstr =~ m/^(\d+)\.(\d+)(?:[.-](\d+))?/) {
-        return 1 if version_cmp($1, $major, $2, $minor, $3 // 0, $release) >= 0;
-        return 0;
-    }
-
-    die "internal error: cannot check version of invalid string '$verstr'";
-}
-
 sub parse_number_sets {
     my ($set) = @_;
     my $res = [];
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 11+ messages in thread

end of thread, other threads:[~2026-06-26 13:12 UTC | newest]

Thread overview: 11+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-06-26 13:10 [PATCH access-control/cluster/common/manager/network/proxmox-widget-toolkit/qemu-server v2 00/10] fix #7294: pool: add SDN VNets as pool members David Riley
2026-06-26 13:10 ` [PATCH pve-manager v2 01/10] ui: replace var with let to match style guide for variable declaration David Riley
2026-06-26 13:10 ` [PATCH pve-manager v2 02/10] fix #7294: api: pool: add SDN VNets as pool members David Riley
2026-06-26 13:10 ` [PATCH pve-manager v2 03/10] fix #7294: ui: " David Riley
2026-06-26 13:10 ` [PATCH proxmox-widget-toolkit v2 04/10] fix #7294: css: theme: add opacity override for pool VNet icon David Riley
2026-06-26 13:10 ` [PATCH pve-access-control v2 05/10] fix #7294: acl: pool: add SDN VNets as pool members David Riley
2026-06-26 13:10 ` [PATCH pve-network v2 06/10] fix #7294: sdn: register api formats for zones and vnets David Riley
2026-06-26 13:10 ` [PATCH pve-network v2 07/10] fix #7294: sdn: vnet: update pool members on vnet migration and deletion David Riley
2026-06-26 13:10 ` [PATCH pve-common v2 08/10] tools: add helpers for version comparison David Riley
2026-06-26 13:10 ` [PATCH pve-cluster v2 09/10] fix #7294: cluster: helpers: add cluster-wide version assertion David Riley
2026-06-26 13:10 ` [PATCH qemu-server v2 10/10] fix #7294: helpers: use cluster-wide version helper David Riley

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal