public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: David Riley <d.riley@proxmox.com>
To: pve-devel@lists.proxmox.com, Daniel Kral <d.kral@proxmox.com>
Subject: Re: [PATCH ha-manager v2 03/12] rules: node affinity: implement negative node affinity rules
Date: Mon, 13 Jul 2026 14:43:19 +0200	[thread overview]
Message-ID: <03abfed5-06ee-441d-be5b-ca3fefcf0056@proxmox.com> (raw)
In-Reply-To: <20260602100226.180071-4-d.kral@proxmox.com>

Thanks for the patch series.
comments inline.

On 6/2/26 12:04 PM, Daniel Kral wrote:
> Extend the existing node affinity rules plugin to allow users to specify
> negative node affinity constraints, which specify the nodes where HA
> resources SHOULD NOT/MUST NOT be placed.
>
> Negative node affinity rules are internally represented as positive node
> affinity rules, where the positive node affinity rules' nodes set is the
> set complement of the negative node affinity rules' node set. As this is
> semantically equivalent, this allows making no change in the apply logic.
>
> As node priority groups do only hold semantic value for positive node
> affinity rules, reject negative node affinity rules with non-zero
> priorities and add all resulting nodes to the default priority group.
>
> Furthermore, negative node affinity rules, which specify all cluster
> nodes, are dropped as well as their strict variant is unsatisfiable,
> because they effectively restrict HA resource to an empty node set, and
> the non-strict variant is equivalent to no rule at all.
>
> Signed-off-by: Daniel Kral<d.kral@proxmox.com>
> ---
> changes since v1:
> - add rule checker to disallow specifying node priorities for negative
>    node affinity rules (thanks @Fiona!)
> - add rule checker to disallow specifying all cluster nodes
> - rename s/nodes/cluster-nodes/ arguments (thanks @Fiona!)
> - rename wrongly named 'positive_nodes' to 'negative_nodes'
> - add more test cases
>
>   src/PVE/HA/HashTools.pm                       |  20 +++
>   src/PVE/HA/Rules.pm                           |   2 +
>   src/PVE/HA/Rules/NodeAffinity.pm              | 162 +++++++++++++++++-
>   .../defaults-for-node-affinity-rules.cfg      |  15 ++
>   ...efaults-for-node-affinity-rules.cfg.expect |  58 ++++++-
>   ...effective-negative-node-affinity-rules.cfg |  12 ++
>   ...ve-negative-node-affinity-rules.cfg.expect |  17 ++
>   ...iority-in-negative-node-affinity-rules.cfg |  11 ++
>   ...in-negative-node-affinity-rules.cfg.expect |  45 +++++
>   9 files changed, 339 insertions(+), 3 deletions(-)
>   create mode 100644 src/test/rules_cfgs/ineffective-negative-node-affinity-rules.cfg
>   create mode 100644 src/test/rules_cfgs/ineffective-negative-node-affinity-rules.cfg.expect
>   create mode 100644 src/test/rules_cfgs/node-priority-in-negative-node-affinity-rules.cfg
>   create mode 100644 src/test/rules_cfgs/node-priority-in-negative-node-affinity-rules.cfg.expect
>
> diff --git a/src/PVE/HA/HashTools.pm b/src/PVE/HA/HashTools.pm
> index ebe47e38..b6e2136b 100644
> --- a/src/PVE/HA/HashTools.pm
> +++ b/src/PVE/HA/HashTools.pm
> @@ -6,6 +6,7 @@ use warnings;
>   use base qw(Exporter);
>   
>   our @EXPORT_OK = qw(
> +    set_difference
>       set_intersect
>       set_union
>       sets_are_disjoint
> @@ -29,6 +30,25 @@ more verbose implementation.
>   
>   =cut
>   
> +=head3 set_difference($hash1, $hash2)
> +
> +Returns a hash set of the set difference between the hash sets C<$hash1> and
> +C<$hash2>, i.e. the elements that are in C<$hash1> without the elements that
> +are in C<$hash2>.
> +
> +The hashes C<$hash1> and C<$hash2> are expected to be hash sets, i.e.
> +key-value pairs are always set to C<1> or another truthy value.
> +
> +=cut
> +
> +sub set_difference : prototype($$) {


Why did you opt for prototype($$) here?
`set_intersect` and `set_union` don't seem to make use of this.

> +    my ($hash1, $hash2) = @_;
> +
> +    my $result = { map { $hash2->{$_} ? () : ($_ => 1) } keys %$hash1 };
> +
> +    return $result;
> +}
> +
>   =head3 set_intersect($hash1, $hash2)
>   
>   Returns a hash set of the intersection of the hash sets C<$hash1> and
> diff --git a/src/PVE/HA/Rules.pm b/src/PVE/HA/Rules.pm
> index 2b48cb60..bc67de20 100644
> --- a/src/PVE/HA/Rules.pm
> +++ b/src/PVE/HA/Rules.pm
> @@ -461,6 +461,8 @@ sub transform {
>           for my $transform ($transformdef->{$type}->@*) {
>               my $global_args = $class->get_check_arguments($rules);
>   
> +            $global_args->{'cluster-nodes'} = $cluster_nodes;
> +
>               $transform->($rules, $global_args);
>           }
>       }
> diff --git a/src/PVE/HA/Rules/NodeAffinity.pm b/src/PVE/HA/Rules/NodeAffinity.pm
> index 5d4401f6..bbe76720 100644
> --- a/src/PVE/HA/Rules/NodeAffinity.pm
> +++ b/src/PVE/HA/Rules/NodeAffinity.pm
> @@ -9,6 +9,7 @@ use PVE::Cluster;
>   use PVE::JSONSchema qw(get_standard_option);
>   use PVE::Tools;
>   
> +use PVE::HA::HashTools qw(set_difference);
>   use PVE::HA::Rules;
>   use PVE::HA::Tools;
>   
> @@ -28,6 +29,22 @@ PVE::HA::Rules::NodeAffinity
>   This package provides the capability to specify and apply rules, which put
>   affinity constraints between a set of HA resources and a set of nodes.
>   
> +HA Node Affinity rules can be one of two types:
> +
> +=over
> +
> +=item C<'positive'>
> +
> +Positive node affinity rules specify the nodes, which SHOULD/MUST be preferred
> +by the given HA resources.
> +
> +=item C<'negative'>
> +
> +Negative node affinity rules specify the nodes, which SHOULD/MUST be avoided by
> +the given HA resources.
> +
> +=back
> +
>   HA Node Affinity rules can be either C<'non-strict'> or C<'strict'>:
>   
>   =over
> @@ -66,9 +83,10 @@ sub properties {
>           ),
>           affinity => {
>               description => "Describes whether the HA resources are supposed to"
> -                . " be placed on the given nodes ('positive').",
> +                . " be placed on the given nodes ('positive'), or are supposed"
> +                . " to be placed on any but the given nodes ('negative').",
>               type => 'string',
> -            enum => ['positive'],
> +            enum => ['positive', 'negative'],
>               default => 'positive',
>               optional => 1,
>           },
> @@ -256,6 +274,146 @@ __PACKAGE__->register_check(
>       },
>   );
>   
> +=head3 check_nonempty_negative_nodes_complement($node_affinity_rules, $cluster_nodes)
> +
> +Returns a list of negative node affinity rule ids in C<$node_affinity_rules>,
> +where the complement of the negative node set is an empty node set according to
> +the currently configured cluster node list C<$cluster_nodes>, i.e., the
> +negative node set specifies all cluster nodes.
> +
> +Even though this is only relevant for strict negative node affinity rules, this
> +check also includes non-strict negative node affinity rules as their effective
> +node set would be equivalent to setting no rule at all.
> +
> +If there are none, the returned list is empty.
> +
> +=cut
> +
> +sub check_nonempty_negative_nodes_complement {
> +    my ($node_affinity_rules, $cluster_nodes) = @_;
> +
> +    my @conflicts = ();
> +
> +    my $total_node_count = @$cluster_nodes;
> +
> +    while (my ($ruleid, $rule) = each %$node_affinity_rules) {


I'm not a perl expert by any means, but couldn't this be considered problematic.

Consider this scenario:
You have some kind of function that runs before this is being called which,
iterates over the elements exactly like you do:

while (my ($ruleid, $rule) = each %$node_affinity_rules) {
     if ($rule->{affinity} eq 'negative') {
         return $ruleid;
     }
}

but it has this early return statement. So if you have for example rule_a, rule_b
and rule_c and it returns early on rule_a here, the internal iterator could
still point at rule_a and never actually reset.

It might not be the case at the moment, but further down the line this could
be a wild bug to hunt down or am I misinterpreting the perldocs [0].

Might be safer to just use a regular for loop here instead, but after looking
at other parts of the code this seems to be used a lot in this file.
And I also get why it was used here, as it makes it easier to grab the key-value
pairs directly instead of looking them up manually.
Nothing to worry about right away just something to look out for.

[0] https://perldoc.perl.org/functions/each

> +        next if $rule->{affinity} ne 'negative';
> +
> +        push @conflicts, $ruleid if keys $rule->{nodes}->%* >= $total_node_count;
> +    }
> +
> +    @conflicts = sort @conflicts;
> +    return \@conflicts;
> +}
> +
> +__PACKAGE__->register_check(
> +    sub {
> +        my ($args) = @_;
> +
> +        return check_nonempty_negative_nodes_complement(
> +            $args->{node_affinity_rules},
> +            $args->{'cluster-nodes'},
> +        );
> +    },
> +    sub {
> +        my ($ruleids, $errors) = @_;
> +
> +        for my $ruleid (@$ruleids) {
> +            push $errors->{$ruleid}->{nodes}->@*,
> +                "negative node affinity rule must not specify all cluster nodes";
> +        }
> +    },
> +);
> +
> +=head3 check_unprioritized_negative_nodes($node_affinity_rules)
> +
> +Returns a list of negative node affinity rule ids in C<$node_affinity_rules>,
> +where at least one node has a priority set. A node priority does not have any
> +meaningful semantic value for negative node affinity rules.
> +
> +If there are none, the returned list is empty.
> +
> +=cut
> +
> +sub check_unprioritized_negative_nodes {
> +    my ($node_affinity_rules) = @_;
> +
> +    my @conflicts = ();
> +
> +    while (my ($ruleid, $rule) = each %$node_affinity_rules) {
> +        next if $rule->{affinity} ne 'negative';
> +
> +        for my $node (keys $rule->{nodes}->%*) {
> +            if ($rule->{nodes}->{$node}->{priority}) {
> +                push @conflicts, $ruleid;
> +                last; # one non-zero priority is enough to invalidate rule
> +            }
> +        }
> +    }
> +
> +    @conflicts = sort @conflicts;
> +    return \@conflicts;
> +}
> +
> +__PACKAGE__->register_check(
> +    sub {
> +        my ($args) = @_;
> +
> +        return check_unprioritized_negative_nodes($args->{node_affinity_rules});
> +    },
> +    sub {
> +        my ($ruleids, $errors) = @_;
> +
> +        for my $ruleid (@$ruleids) {
> +            push $errors->{$ruleid}->{nodes}->@*,
> +                "negative node affinity rule must not specify node priorities";
> +        }
> +    },
> +);
> +
> +=head1 NODE AFFINITY RULE TRANSFORMATION HELPERS
> +
> +=cut
> +
> +=head3 invert_negative_node_affinity_rules($rules, $node_affinity_rules, $cluster_nodes)
> +
> +Modifies C<$rules> such that all negative node affinity rules, defined in
> +C<$node_affinity_rules>, are transformed to positive node affinity rules, where
> +the nodes set is the complement of the negative node affinity rules' nodes set.
> +
> +C<$cluster_nodes> is a list of the configured cluster nodes, which is used as
> +the universal set to build the complement node set.
> +
> +=cut
> +
> +sub invert_negative_node_affinity_rules {
> +    my ($rules, $node_affinity_rules, $cluster_nodes) = @_;
> +
> +    # set_difference(...) requires a hash set instead of a list
> +    my $cluster_nodes_hash = { map { $_ => 1 } @$cluster_nodes };
> +
> +    while (my ($node_affinity_id, $node_affinity_rule) = each %$node_affinity_rules) {
> +        next if $node_affinity_rule->{affinity} ne 'negative';
> +
> +        my $negative_nodes = { map { $_ => 1 } keys $node_affinity_rule->{nodes}->%* };
> +        my $positive_nodes = set_difference($cluster_nodes_hash, $negative_nodes);
> +        $positive_nodes->{$_} = { priority => 0 } for keys %$positive_nodes;
> +
> +        $rules->{ids}->{$node_affinity_id}->{affinity} = 'positive';
> +        $rules->{ids}->{$node_affinity_id}->{nodes} = $positive_nodes;
> +    }
> +}
> +
> +__PACKAGE__->register_transform(sub {
> +    my ($rules, $args) = @_;
> +
> +    invert_negative_node_affinity_rules(
> +        $rules,
> +        $args->{node_affinity_rules},
> +        $args->{'cluster-nodes'},
> +    );
> +});
> +
>   =head1 NODE AFFINITY RULE HELPERS
>   
>   =cut


nit: might be nice to split the tests into a separate commit but no hard feelings.

> diff --git a/src/test/rules_cfgs/defaults-for-node-affinity-rules.cfg b/src/test/rules_cfgs/defaults-for-node-affinity-rules.cfg
> index 27658562..b89d725d 100644
> --- a/src/test/rules_cfgs/defaults-for-node-affinity-rules.cfg
> +++ b/src/test/rules_cfgs/defaults-for-node-affinity-rules.cfg
> @@ -20,3 +20,18 @@ node-affinity: node-affinity-strict
>   	resources vm:104
>   	nodes node3
>   	strict 1
> +
> +# Case 5: Non-strict negative node affinity rule is transformed to positive node affinity rule with complement node set
> +#         and specified nodes as fallback internally.
> +node-affinity: negative-node-affinity-defaults
> +	resources vm:105
> +	nodes node2
> +	affinity negative
> +
> +# Case 6: Strict negative node affinity rule is transformed to positive node affinity rule with complement node set
> +#         internally.
> +node-affinity: negative-node-affinity-strict
> +	resources vm:106
> +	nodes node3
> +	affinity negative
> +	strict 1
> diff --git a/src/test/rules_cfgs/defaults-for-node-affinity-rules.cfg.expect b/src/test/rules_cfgs/defaults-for-node-affinity-rules.cfg.expect
> index 0d6e5605..877f1967 100644
> --- a/src/test/rules_cfgs/defaults-for-node-affinity-rules.cfg.expect
> +++ b/src/test/rules_cfgs/defaults-for-node-affinity-rules.cfg.expect
> @@ -1,8 +1,39 @@
>   --- Log ---
>   --- Config ---
>   {
> -   "digest" : "fcad82def12abc4422061b79cfd0399967053d93",
> +   "digest" : "308a8b704d8b4dc3fb83d8c97c86133caf735755",
>      "ids" : {
> +      "negative-node-affinity-defaults" : {
> +         "affinity" : "positive",
> +         "nodes" : {
> +            "node1" : {
> +               "priority" : 0
> +            },
> +            "node3" : {
> +               "priority" : 0
> +            }
> +         },
> +         "resources" : {
> +            "vm:105" : 1
> +         },
> +         "type" : "node-affinity"
> +      },
> +      "negative-node-affinity-strict" : {
> +         "affinity" : "positive",
> +         "nodes" : {
> +            "node1" : {
> +               "priority" : 0
> +            },
> +            "node2" : {
> +               "priority" : 0
> +            }
> +         },
> +         "resources" : {
> +            "vm:106" : 1
> +         },
> +         "strict" : 1,
> +         "type" : "node-affinity"
> +      },
>         "node-affinity-defaults" : {
>            "affinity" : "positive",
>            "nodes" : {
> @@ -56,6 +87,8 @@
>         }
>      },
>      "order" : {
> +      "negative-node-affinity-defaults" : 5,
> +      "negative-node-affinity-strict" : 6,
>         "node-affinity-defaults" : 1,
>         "node-affinity-disabled" : 2,
>         "node-affinity-disabled-explicit" : 3,
> @@ -84,6 +117,29 @@
>                  "priority" : 0
>               }
>            }
> +      },
> +      "vm:105" : {
> +         "nodes" : {
> +            "node1" : {
> +               "priority" : 0
> +            },
> +            "node2" : {
> +               "priority" : -1
> +            },
> +            "node3" : {
> +               "priority" : 0
> +            }
> +         }
> +      },
> +      "vm:106" : {
> +         "nodes" : {
> +            "node1" : {
> +               "priority" : 0
> +            },
> +            "node2" : {
> +               "priority" : 0
> +            }
> +         }
>         }
>      },
>      "resource-affinity" : {
> diff --git a/src/test/rules_cfgs/ineffective-negative-node-affinity-rules.cfg b/src/test/rules_cfgs/ineffective-negative-node-affinity-rules.cfg
> new file mode 100644
> index 00000000..a238799a
> --- /dev/null
> +++ b/src/test/rules_cfgs/ineffective-negative-node-affinity-rules.cfg
> @@ -0,0 +1,12 @@
> +# Case 1: Remove non-strict negative node affinity rule, which specifies all cluster nodes.
> +node-affinity: nonstrict-negative-node-affinity-with-all-cluster-nodes
> +    resources vm:101
> +    nodes node1,node2,node3
> +    affinity negative
> +
> +# Case 2: Remove strict negative node affinity rule, which specifies all cluster nodes.
> +node-affinity: strict-negative-node-affinity-with-all-cluster-nodes
> +    resources vm:102
> +    nodes node1,node2,node3
> +    affinity negative
> +    strict
> diff --git a/src/test/rules_cfgs/ineffective-negative-node-affinity-rules.cfg.expect b/src/test/rules_cfgs/ineffective-negative-node-affinity-rules.cfg.expect
> new file mode 100644
> index 00000000..dec03663
> --- /dev/null
> +++ b/src/test/rules_cfgs/ineffective-negative-node-affinity-rules.cfg.expect
> @@ -0,0 +1,17 @@
> +--- Log ---
> +Drop rule 'nonstrict-negative-node-affinity-with-all-cluster-nodes', because negative node affinity rule must not specify all cluster nodes.
> +Drop rule 'strict-negative-node-affinity-with-all-cluster-nodes', because negative node affinity rule must not specify all cluster nodes.
> +--- Config ---
> +{
> +   "digest" : "7a68821f23f0b5b110bfac64a66fd7a01564dd74",
> +   "ids" : {},
> +   "order" : {}
> +}
> +--- Compiled Config ---
> +{
> +   "node-affinity" : {},
> +   "resource-affinity" : {
> +      "negative" : {},
> +      "positive" : {}
> +   }
> +}
> diff --git a/src/test/rules_cfgs/node-priority-in-negative-node-affinity-rules.cfg b/src/test/rules_cfgs/node-priority-in-negative-node-affinity-rules.cfg
> new file mode 100644
> index 00000000..64a2fd63
> --- /dev/null
> +++ b/src/test/rules_cfgs/node-priority-in-negative-node-affinity-rules.cfg
> @@ -0,0 +1,11 @@
> +# Case 1: Do not remove negative node affinity rules with node priorities set to zero as it is equivalent to no node priorities set at all.
> +node-affinity: negative-node-affinity-with-default-node-priorities
> +    resources vm:101
> +    nodes node1:0,node2:0
> +    affinity negative
> +
> +# Case 2: Remove negative node affinity rules with non-zero node priorities.
> +node-affinity: negative-node-affinity-with-non-zero-node-priorities
> +    resources vm:102
> +    nodes node1:2,node2:1
> +    affinity negative
> diff --git a/src/test/rules_cfgs/node-priority-in-negative-node-affinity-rules.cfg.expect b/src/test/rules_cfgs/node-priority-in-negative-node-affinity-rules.cfg.expect
> new file mode 100644
> index 00000000..ef50b25e
> --- /dev/null
> +++ b/src/test/rules_cfgs/node-priority-in-negative-node-affinity-rules.cfg.expect
> @@ -0,0 +1,45 @@
> +--- Log ---
> +Drop rule 'negative-node-affinity-with-non-zero-node-priorities', because negative node affinity rule must not specify node priorities.
> +--- Config ---
> +{
> +   "digest" : "d95cf03b7f6c8078854506cc79127fd942b868bd",
> +   "ids" : {
> +      "negative-node-affinity-with-default-node-priorities" : {
> +         "affinity" : "positive",
> +         "nodes" : {
> +            "node3" : {
> +               "priority" : 0
> +            }
> +         },
> +         "resources" : {
> +            "vm:101" : 1
> +         },
> +         "type" : "node-affinity"
> +      }
> +   },
> +   "order" : {
> +      "negative-node-affinity-with-default-node-priorities" : 1
> +   }
> +}
> +--- Compiled Config ---
> +{
> +   "node-affinity" : {
> +      "vm:101" : {
> +         "nodes" : {
> +            "node1" : {
> +               "priority" : -1
> +            },
> +            "node2" : {
> +               "priority" : -1
> +            },
> +            "node3" : {
> +               "priority" : 0
> +            }
> +         }
> +      }
> +   },
> +   "resource-affinity" : {
> +      "negative" : {},
> +      "positive" : {}
> +   }
> +}




  reply	other threads:[~2026-07-13 12:43 UTC|newest]

Thread overview: 26+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-06-02 10:01 [PATCH-SERIES docs/ha-manager/manager v2 00/12] Negative Node Affinity Rules Daniel Kral
2026-06-02 10:01 ` [PATCH ha-manager v2 01/12] rules: node affinity: add affinity property to node affinity rules Daniel Kral
2026-06-02 10:01 ` [PATCH ha-manager v2 02/12] rules: rename ambiguous argument nodes to cluster nodes Daniel Kral
2026-07-13 12:41   ` David Riley
2026-06-02 10:01 ` [PATCH ha-manager v2 03/12] rules: node affinity: implement negative node affinity rules Daniel Kral
2026-07-13 12:43   ` David Riley [this message]
2026-07-14  7:36     ` Daniel Kral
2026-06-02 10:01 ` [PATCH manager v2 04/12] ui: ha: node affinity: handle non-existent nodes Daniel Kral
2026-06-02 10:01 ` [PATCH manager v2 05/12] ui: ha: node affinity: do update node selection all at once Daniel Kral
2026-06-02 10:01 ` [PATCH manager v2 06/12] ui: ha: node affinity: move node priority selector into separate component Daniel Kral
2026-07-13 12:43   ` David Riley
2026-07-14  7:57     ` Daniel Kral
2026-07-14  8:51       ` David Riley
2026-06-02 10:01 ` [PATCH manager v2 07/12] ui: ha: node affinity: allow setting affinity for node affinity rules Daniel Kral
2026-07-13 12:43   ` David Riley
2026-07-14  8:39     ` Daniel Kral
2026-06-02 10:01 ` [PATCH manager v2 08/12] ui: ha: node affinity: do not send default node affinity rule values Daniel Kral
2026-07-13 12:43   ` David Riley
2026-06-02 10:01 ` [PATCH docs v2 09/12] ha-manager: rules: use the correct article for terms starting with HA Daniel Kral
2026-07-13 12:43   ` David Riley
2026-06-02 10:01 ` [PATCH docs v2 10/12] ha-manager: rules: improve resource affinity rule short description Daniel Kral
2026-07-13 12:44   ` David Riley
2026-06-02 10:01 ` [PATCH docs v2 11/12] ha-manager: rules: adapt rule configuration examples Daniel Kral
2026-07-13 12:44   ` David Riley
2026-06-02 10:01 ` [PATCH docs v2 12/12] ha-manager: rules: add negative node affinity rule descriptions Daniel Kral
2026-07-13 12:44   ` David Riley

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=03abfed5-06ee-441d-be5b-ca3fefcf0056@proxmox.com \
    --to=d.riley@proxmox.com \
    --cc=d.kral@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