public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH pve-firewall v4 11/27] firewall: tests: add tests for object reference updates
Date: Thu, 22 Oct 2026 14:46:52 +0200	[thread overview]
Message-ID: <20261022124708.429069-12-a.bied-charreton@proxmox.com> (raw)
In-Reply-To: <20261022124708.429069-1-a.bied-charreton@proxmox.com>

The reference updating logic has quite a few edge cases, especially
regarding resolving unscoped references.

Add a tests to consolidate the intended functionality. Note that these
tests do not cover the actual iteration over the different configs in
the cluster, rather they are focused on the core reference rewriting
logic.

Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
 src/test/Makefile            |   2 +-
 src/test/test_update_refs.pl | 375 +++++++++++++++++++++++++++++++++++
 2 files changed, 376 insertions(+), 1 deletion(-)
 create mode 100755 src/test/test_update_refs.pl

diff --git a/src/test/Makefile b/src/test/Makefile
index 272a19a..ea3e378 100644
--- a/src/test/Makefile
+++ b/src/test/Makefile
@@ -1,4 +1,4 @@
-TESTS = test_parse_address_list test_ipt_rule_to_cmds test_parameter_newlines test_compile_ipsets test_simulator_protocols test_interface_names test_restore_errors
+TESTS = test_parse_address_list test_ipt_rule_to_cmds test_parameter_newlines test_compile_ipsets test_simulator_protocols test_interface_names test_restore_errors test_update_refs
 
 .PHONY: all test $(TESTS) clean
 all: test
diff --git a/src/test/test_update_refs.pl b/src/test/test_update_refs.pl
new file mode 100755
index 0000000..f148eed
--- /dev/null
+++ b/src/test/test_update_refs.pl
@@ -0,0 +1,375 @@
+#!/usr/bin/env perl
+
+# tests for the alias/ipset reference updating logic
+
+use lib qw(..);
+
+use v5.36;
+
+use Test::More;
+
+use PVE::Firewall::References;
+
+sub rewrite($conf, $kind, $old, $new, $env, $is_guest, $action = undef, $cluster_conf = {}) {
+    my $op = {
+        kind => $kind,
+        old => ref($old) eq 'ARRAY' ? $old : [$old],
+        new => $new,
+        action => $action // (defined($new) ? 'rename' : 'disable'),
+        cluster_conf => $cluster_conf,
+    };
+    return PVE::Firewall::References::rewrite_refs_in_conf($conf, $op, $env, $is_guest);
+}
+
+subtest 'ipset: cluster rename rewrites dc/ and bare refs' => sub {
+    my $conf = {
+        rules => [
+            { source => '+dc/foo' },
+            { dest => '+foo' },
+            { source => '+bar' },
+            { source => '+dc/bar' },
+        ],
+        groups => {
+            grp => [{ source => '+dc/foo' }, { dest => '+foo' }],
+        },
+        ipset => {
+            set => [{ cidr => '10.0.0.0/8' }],
+        },
+    };
+
+    my $changes = rewrite($conf, 'ipset', 'foo', 'baz', 'cluster', 0);
+
+    ok(scalar($changes->@*), 'reports changes');
+    is($conf->{rules}->[0]->{source}, '+dc/baz', 'dc/ ref rewritten');
+    is($conf->{rules}->[1]->{dest}, '+baz', 'bare ref rewritten');
+    is($conf->{rules}->[2]->{source}, '+bar', 'unrelated bare ref kept');
+    is($conf->{rules}->[3]->{source}, '+dc/bar', 'unrelated dc/ ref kept');
+    is($conf->{groups}->{grp}->[0]->{source}, '+dc/baz', 'group dc/ ref rewritten');
+    is($conf->{groups}->{grp}->[1]->{dest}, '+baz', 'group bare ref rewritten');
+    is($conf->{ipset}->{set}->[0]->{cidr}, '10.0.0.0/8', 'ipset member not touched');
+};
+
+subtest 'ipset: cluster delete disables matching rules' => sub {
+    my $conf = {
+        rules => [
+            { source => '+dc/foo', enable => 1 },
+            { source => '+foo', enable => 1 },
+            { source => '+other', enable => 1 },
+        ],
+        groups =>
+            { grp => [{ dest => '+dc/foo', enable => 1 }, { dest => '+keep', enable => 1 }] },
+    };
+
+    my $changes = rewrite($conf, 'ipset', 'foo', undef, 'cluster', 0);
+
+    ok(scalar($changes->@*), 'reports changes');
+    is(scalar($conf->{rules}->@*), 3, 'no rule dropped');
+    is($conf->{rules}->[0]->{enable}, 0, 'dc/ ref rule disabled');
+    is($conf->{rules}->[1]->{enable}, 0, 'bare ref rule disabled');
+    is($conf->{rules}->[2]->{enable}, 1, 'unrelated rule untouched');
+    is($conf->{groups}->{grp}->[0]->{enable}, 0, 'group rule disabled');
+    is($conf->{groups}->{grp}->[1]->{enable}, 1, 'unrelated group rule untouched');
+};
+
+subtest 'ipset: cluster drop removes matching rules' => sub {
+    my $conf = {
+        rules => [
+            { source => '+dc/foo', enable => 1 },
+            { source => '+foo', enable => 1 },
+            { source => '+other', enable => 1 },
+        ],
+        groups =>
+            { grp => [{ dest => '+dc/foo', enable => 1 }, { dest => '+keep', enable => 1 }] },
+    };
+
+    my $changes = rewrite($conf, 'ipset', 'foo', undef, 'cluster', 0, 'drop');
+
+    ok(scalar($changes->@*), 'reports changes');
+    is(scalar($conf->{rules}->@*), 1, 'matching rules dropped');
+    is($conf->{rules}->[0]->{source}, '+other', 'unrelated rule kept');
+    is($conf->{rules}->[0]->{enable}, 1, 'kept rule not disabled');
+    is(scalar($conf->{groups}->{grp}->@*), 1, 'matching group rule dropped');
+    is($conf->{groups}->{grp}->[0]->{dest}, '+keep', 'unrelated group rule kept');
+};
+
+subtest 'ipset: guest shadows cluster object' => sub {
+    my $conf = {
+        rules => [
+            { source => '+foo' }, { dest => '+dc/foo' },
+        ],
+        ipset => { foo => [] },
+    };
+
+    my $changes = rewrite($conf, 'ipset', 'foo', 'baz', 'cluster', 1);
+
+    ok(scalar($changes->@*), 'reports changes');
+    is($conf->{rules}->[0]->{source}, '+foo', 'shadowed bare ref not touched');
+    is($conf->{rules}->[1]->{dest}, '+dc/baz', 'explicit cluster ref rewritten');
+};
+
+subtest 'ipset: guest without own object' => sub {
+    my $conf = {
+        rules => [{ source => '+foo' }, { dest => '+dc/foo' }],
+        ipset => {},
+    };
+
+    rewrite($conf, 'ipset', 'foo', 'baz', 'cluster', 1);
+
+    is($conf->{rules}->[0]->{source}, '+baz', 'implicit cluster ref rewritten');
+    is($conf->{rules}->[1]->{dest}, '+dc/baz', 'explicit cluster ref rewritten');
+};
+
+subtest 'ipset: guest-level rename' => sub {
+    my $conf = {
+        rules => [
+            { source => '+foo' }, { dest => '+guest/foo' }, { source => '+dc/foo' },
+        ],
+    };
+
+    rewrite($conf, 'ipset', 'foo', 'baz', 'vm', 0);
+
+    is($conf->{rules}->[0]->{source}, '+baz', 'implicit guest ref rewritten');
+    is($conf->{rules}->[1]->{dest}, '+guest/baz', 'guest/ ref rewritten');
+    is($conf->{rules}->[2]->{source}, '+dc/foo', 'dc/ ref not touched in guest env');
+};
+
+subtest 'ipset: sdn scope, shadowing and multiple objects' => sub {
+    my $conf = {
+        rules => [
+            { source => '+sdn/vnet0-all', enable => 1 },
+            { dest => '+vnet0-all', enable => 1 },
+            { source => '+sdn/vnet0-dhcp', enable => 1 },
+            { dest => '+vnet0-dhcp', enable => 1 },
+            { source => '+dc/vnet0-all', enable => 1 },
+        ],
+        # shadows the SDN-generated IPSet of the same name
+        ipset => { 'vnet0-dhcp' => [] },
+    };
+
+    my $vnet_ipsets = ['vnet0-all', 'vnet0-dhcp'];
+    my $changes = rewrite($conf, 'ipset', $vnet_ipsets, undef, 'sdn', 0, 'disable', $conf);
+
+    ok(scalar($changes->@*), 'reports changes');
+    is($conf->{rules}->[0]->{enable}, 0, 'sdn/ ref disabled');
+    is($conf->{rules}->[1]->{enable}, 0, 'bare ref disabled');
+    is($conf->{rules}->[2]->{enable}, 0, 'sdn/ ref disabled despite cluster IPSet');
+    is($conf->{rules}->[3]->{enable}, 1, 'bare ref kept, resolves to the cluster IPSet');
+    is($conf->{rules}->[4]->{enable}, 1, 'dc/ ref not touched in sdn env');
+
+    my $guest_conf = {
+        rules => [
+            { source => '+sdn/vnet0-all', enable => 1 }, { dest => '+vnet0-all', enable => 1 },
+        ],
+        ipset => { 'vnet0-all' => [] },
+    };
+
+    rewrite($guest_conf, 'ipset', $vnet_ipsets, undef, 'sdn', 1, 'disable', {});
+
+    is($guest_conf->{rules}->[0]->{enable}, 0, 'sdn/ ref disabled in guest conf');
+    is($guest_conf->{rules}->[1]->{enable}, 1, 'bare ref kept, resolves to the guest IPSet');
+};
+
+subtest 'ipset: not confused with alias' => sub {
+    my $conf = { rules => [{ source => 'foo' }, { dest => '+foo' }] };
+
+    my $changes = rewrite($conf, 'ipset', 'foo', 'baz', 'cluster', 0);
+
+    ok(scalar($changes->@*), 'reports changes');
+    is($conf->{rules}->[0]->{source}, 'foo', 'bare alias ref not touched');
+    is($conf->{rules}->[1]->{dest}, '+baz', 'ipset ref rewritten');
+};
+
+subtest 'alias: cluster rename rewrites rules and ipset members' => sub {
+    my $conf = {
+        rules => [{ source => 'al' }, { dest => 'dc/al' }, { source => 'other' }],
+        groups => { grp => [{ source => 'dc/al' }] },
+        ipset => {
+            set => [{ cidr => 'al' }, { cidr => 'dc/al' }, { cidr => '10.0.0.1' }],
+        },
+    };
+
+    my $changes = rewrite($conf, 'aliases', 'al', 'new', 'cluster', 0);
+
+    ok(scalar($changes->@*), 'reports changes');
+    is($conf->{rules}->[0]->{source}, 'new', 'bare alias in rule rewritten');
+    is($conf->{rules}->[1]->{dest}, 'dc/new', 'dc/ alias in rule rewritten');
+    is($conf->{rules}->[2]->{source}, 'other', 'unrelated rule kept');
+    is($conf->{groups}->{grp}->[0]->{source}, 'dc/new', 'alias in group rewritten');
+    is($conf->{ipset}->{set}->[0]->{cidr}, 'new', 'bare alias in ipset member rewritten');
+    is($conf->{ipset}->{set}->[1]->{cidr}, 'dc/new', 'dc/ alias in ipset member rewritten');
+    is($conf->{ipset}->{set}->[2]->{cidr}, '10.0.0.1', 'literal cidr member kept');
+};
+
+subtest 'alias: cluster delete disables rules, drops ipset members' => sub {
+    my $conf = {
+        rules => [
+            { source => 'al', dest => '10.0.0.1', action => 'ACCEPT', enable => 1 },
+            { source => 'keep', enable => 1 },
+        ],
+        groups =>
+            { grp => [{ source => 'dc/al', enable => 1 }, { source => 'keep', enable => 1 }] },
+        ipset => {
+            set =>
+                [{ cidr => 'al' }, { cidr => 'dc/al', nomatch => 1 }, { cidr => '10.0.0.1' }],
+        },
+    };
+
+    my $changes = rewrite($conf, 'aliases', 'al', undef, 'cluster', 0);
+
+    ok(scalar($changes->@*), 'reports changes');
+
+    is(scalar($conf->{rules}->@*), 2, 'no rule dropped');
+    is($conf->{rules}->[0]->{enable}, 0, 'matching rule disabled');
+    is($conf->{rules}->[0]->{source}, 'al', 'disabled rule keeps its reference');
+    is($conf->{rules}->[0]->{dest}, '10.0.0.1', 'disabled rule keeps its other properties');
+    is($conf->{rules}->[1]->{enable}, 1, 'unrelated rule untouched');
+
+    is(scalar($conf->{groups}->{grp}->@*), 2, 'no group rule dropped');
+    is($conf->{groups}->{grp}->[0]->{enable}, 0, 'matching group rule disabled');
+    is($conf->{groups}->{grp}->[1]->{enable}, 1, 'unrelated group rule untouched');
+
+    is(scalar($conf->{ipset}->{set}->@*), 1, 'matching members dropped, not disabled');
+    is($conf->{ipset}->{set}->[0]->{cidr}, '10.0.0.1', 'literal member kept');
+    ok(
+        !(grep { exists($_->{enable}) } $conf->{ipset}->{set}->@*),
+        'no ipset member carries an enable flag',
+    );
+};
+
+subtest 'alias: cluster drop removes rules and ipset members' => sub {
+    my $conf = {
+        rules => [{ source => 'al', enable => 1 }, { source => 'keep', enable => 1 }],
+        ipset => { set => [{ cidr => 'al' }, { cidr => 'dc/al' }, { cidr => '10.0.0.1' }] },
+    };
+
+    rewrite($conf, 'aliases', 'al', undef, 'cluster', 0, 'drop');
+
+    is(scalar($conf->{rules}->@*), 1, 'matching rule dropped');
+    is($conf->{rules}->[0]->{source}, 'keep', 'unrelated rule kept');
+    is($conf->{rules}->[0]->{enable}, 1, 'kept rule not disabled');
+    is(scalar($conf->{ipset}->{set}->@*), 1, 'matching members dropped');
+    is($conf->{ipset}->{set}->[0]->{cidr}, '10.0.0.1', 'literal member kept');
+};
+
+subtest 'alias: not confused with ipset' => sub {
+    my $conf = { rules => [{ source => '+al' }, { dest => 'al' }] };
+
+    rewrite($conf, 'aliases', 'al', 'new', 'cluster', 0);
+
+    is($conf->{rules}->[0]->{source}, '+al', 'ipset ref (+) not touched');
+    is($conf->{rules}->[1]->{dest}, 'new', 'alias ref rewritten');
+};
+
+subtest 'case-insensitive match, written back lowercase' => sub {
+    my $conf = { rules => [{ source => '+DC/FOO' }, { dest => '+Foo' }] };
+
+    rewrite($conf, 'ipset', 'foo', 'baz', 'cluster', 0);
+
+    is($conf->{rules}->[0]->{source}, '+dc/baz', 'dc/ ref matched and lowercased');
+    is($conf->{rules}->[1]->{dest}, '+baz', 'bare ref matched and lowercased');
+};
+
+subtest 'no match reports no changes' => sub {
+    my $conf = { rules => [{ source => '+other' }, { dest => 'somealias' }] };
+
+    my $changes = rewrite($conf, 'ipset', 'foo', 'baz', 'cluster', 0);
+
+    is(scalar($changes->@*), 0, 'nothing matched -> no changes');
+    is($conf->{rules}->[0]->{source}, '+other', 'unrelated refs not touched');
+};
+
+subtest 'changes describe every reference that was touched' => sub {
+    my $conf = {
+        rules => [{ source => 'al', enable => 1 }, { source => 'keep', enable => 1 }],
+        groups => { grp => [{ dest => 'dc/al', enable => 1 }] },
+        ipset => { set => [{ cidr => '10.0.0.1' }, { cidr => 'al' }] },
+    };
+
+    my $changes = rewrite($conf, 'aliases', 'al', undef, 'cluster', 0, 'drop');
+
+    is(scalar($changes->@*), 3, 'one change per reference');
+
+    my $by_section = { map { ($_->{section} => $_) } $changes->@* };
+
+    is($by_section->{rules}->{pos}, 0, 'rule position recorded');
+    is($by_section->{rules}->{ref}, 'al', 'matched reference recorded');
+    is($by_section->{rules}->{action}, 'drop', 'action recorded');
+    is($by_section->{group}->{name}, 'grp', 'group name recorded');
+    is($by_section->{group}->{ref}, 'dc/al', 'scoped reference recorded');
+    is($by_section->{ipset}->{name}, 'set', 'ipset name recorded');
+    is($by_section->{ipset}->{pos}, 1, 'member position recorded, literal member skipped');
+
+    # update_refs tags each change with the config it was made in
+    $_->{scope} = 'cluster' for $changes->@*;
+
+    is(
+        PVE::Firewall::References::format_ref_change($by_section->{ipset}),
+        "cluster: ipset 'set': member 1: 'al' dropped",
+        'drop formatted for the task log',
+    );
+};
+
+subtest 'rename changes record the new reference' => sub {
+    my $conf = { rules => [{ source => '+dc/foo' }] };
+
+    my $changes = rewrite($conf, 'ipset', 'foo', 'baz', 'cluster', 0);
+
+    is($changes->[0]->{new}, '+dc/baz', 'new reference recorded');
+
+    $changes->[0]->{scope} = 'vm/100';
+
+    is(
+        PVE::Firewall::References::format_ref_change($changes->[0]),
+        "vm/100: rules: rule 0: '+dc/foo' renamed to '+dc/baz'",
+        'rename formatted for the task log',
+    );
+};
+
+subtest 'update_refs: rejects invalid argument combinations' => sub {
+    my $run = sub {
+        my (%args) = @_;
+
+        eval {
+            PVE::Firewall::References::update_refs({
+                conf => { rules => [] },
+                kind => 'ipset',
+                env => 'vm',
+                subject => 'test',
+                log => sub { },
+                %args,
+            });
+        };
+
+        return $@;
+    };
+
+    like(
+        $run->(old => 'foo', new => 'baz', action => 'bogus'),
+        qr/invalid action/,
+        'unknown action rejected',
+    );
+    like(
+        $run->(kind => 'bogus', old => 'foo', new => 'baz', action => 'rename'),
+        qr/unknown object kind/,
+        'unknown object kind rejected',
+    );
+    like(
+        $run->(old => ['foo', 'bar'], new => 'baz', action => 'rename'),
+        qr/cannot rename more than one object/,
+        'renaming several objects rejected',
+    );
+    like(
+        $run->(old => 'foo', new => 'baz', env => 'cluster', action => 'rename'),
+        qr/\$save is required/,
+        'cluster rename without a save callback rejected',
+    );
+    # it goes on to walk the cluster, which this has no business doing, but it gets past the
+    # argument checks without a save callback
+    unlike(
+        $run->(old => 'foo', env => 'cluster', action => 'drop'),
+        qr/\$save is required/,
+        'a drop needs no save callback',
+    );
+};
+
+done_testing();
-- 
2.47.3





  parent reply	other threads:[~2026-10-22 12:50 UTC|newest]

Thread overview: 28+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-22 12:46 [PATCH container/docs/firewall/manager/network/qemu-server v4 00/27] handle dangling references when firewall objects go away Arthur Bied-Charreton
2026-10-22 12:46 ` [PATCH pve-firewall v4 01/27] firewall: move object reference helpers to their own module Arthur Bied-Charreton
2026-10-22 12:46 ` [PATCH pve-firewall v4 02/27] firewall: references: add helpers to update object references Arthur Bied-Charreton
2026-10-22 12:46 ` [PATCH pve-firewall v4 03/27] firewall: references: log the references an object update touched Arthur Bied-Charreton
2026-10-22 12:46 ` [PATCH pve-firewall v4 04/27] firewall: references: add wrappers for the SDN-generated IPSets Arthur Bied-Charreton
2026-10-22 12:46 ` [PATCH pve-firewall v4 05/27] firewall: parser: do not log errors for disabled rules Arthur Bied-Charreton
2026-10-22 12:46 ` [PATCH pve-firewall v4 06/27] firewall: ipset: add option to handle dangling references on rename Arthur Bied-Charreton
2026-10-22 12:46 ` [PATCH pve-firewall v4 07/27] firewall: ipset: add option to handle dangling references on delete Arthur Bied-Charreton
2026-10-22 12:46 ` [PATCH pve-firewall v4 08/27] firewall: aliases: add option to handle dangling references on rename Arthur Bied-Charreton
2026-10-22 12:46 ` [PATCH pve-firewall v4 09/27] firewall: aliases: add option to handle dangling references on delete Arthur Bied-Charreton
2026-10-22 12:46 ` [PATCH pve-firewall v4 10/27] firewall: api: run reference updates as tasks Arthur Bied-Charreton
2026-10-22 12:46 ` Arthur Bied-Charreton [this message]
2026-10-22 12:46 ` [PATCH pve-firewall v4 12/27] firewall: references: handle security groups Arthur Bied-Charreton
2026-10-22 12:46 ` [PATCH pve-firewall v4 13/27] firewall: groups: add option to handle dangling references on rename Arthur Bied-Charreton
2026-10-22 12:46 ` [PATCH pve-firewall v4 14/27] fix #8176: firewall: groups: handle dangling references on delete Arthur Bied-Charreton
2026-10-22 12:46 ` [PATCH pve-network v4 15/27] apply: add option to handle dangling references on VNet deletion Arthur Bied-Charreton
2026-10-22 12:46 ` [PATCH qemu-server v4 16/27] api: destroy_vm: add option to handle dangling IPSet references Arthur Bied-Charreton
2026-10-22 12:46 ` [PATCH pve-container v4 17/27] " Arthur Bied-Charreton
2026-10-22 12:46 ` [PATCH pve-docs v4 18/27] firewall: remove trailing spaces Arthur Bied-Charreton
2026-10-22 12:47 ` [PATCH pve-docs v4 19/27] firewall: document renaming and removing objects Arthur Bied-Charreton
2026-10-22 12:47 ` [PATCH pve-manager v4 20/27] ui: firewall: add a common widget for handling dangling references Arthur Bied-Charreton
2026-10-22 12:47 ` [PATCH pve-manager v4 21/27] ui: firewall: ipset: add controls for " Arthur Bied-Charreton
2026-10-22 12:47 ` [PATCH pve-manager v4 22/27] ui: firewall: aliases: " Arthur Bied-Charreton
2026-10-22 12:47 ` [PATCH pve-manager v4 23/27] ui: sdn: apply: add control for dangling IPSet references Arthur Bied-Charreton
2026-10-22 12:47 ` [PATCH pve-manager v4 24/27] ui: guest destroy: use let for non-constant variable bindings Arthur Bied-Charreton
2026-10-22 12:47 ` [PATCH pve-manager v4 25/27] ui: guest destroy: add control for dangling IPSet references Arthur Bied-Charreton
2026-10-22 12:47 ` [PATCH pve-manager v4 26/27] ui: firewall: show the task for reference updates Arthur Bied-Charreton
2026-10-22 12:47 ` [PATCH pve-manager v4 27/27] ui: firewall: groups: add controls for dangling references Arthur Bied-Charreton

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=20261022124708.429069-12-a.bied-charreton@proxmox.com \
    --to=a.bied-charreton@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal