Implement namespace selection by label in resource policy

Add includedNamespacesByLabel, excludedNamespacesByLabel, and
labelSelectorLogic to IncludeExcludePolicy in the ResourcePolicy
ConfigMap (realizes design in velero-io/velero#9772), letting a backup
select or exclude namespaces by label instead of (or in addition to)
name/wildcard.

The backup controller resolves label selectors against the live
namespace list once per backup, merges the results into
spec.includedNamespaces/excludedNamespaces, then proceeds through the
existing name-based filtering unchanged. A defaulted "*" include list
is replaced by the resolved set; an explicitly-configured include list
(including an explicit "*") is unioned with it instead, and stays
canonical rather than widening. Namespaces matching an exclude
selector are always subtracted from the merged includes, regardless of
how the includes were populated.

Because Velero's namespace-includes/excludes model requires at least
one name (an empty list means "match everything"), a selector that
resolves to zero namespaces is represented with a sentinel glob
pattern ("[-]*") guaranteed to match no real namespace, rather than an
empty list that would silently fall back to including/excluding
everything.

labelSelectorLogic ("AND"/"OR", case-insensitive) controls whether
multiple included/excluded label selectors are combined by
intersection or union; it is validated up front, including inside
ResolveNamespacesByLabel itself, so an invalid value fails fast instead
of silently falling through to OR semantics.

Namespace-selection-by-label and resource-selection-by-label act as
independent axes and do not affect each other, matching the design
discussion in #9772.

Known limitations:
- Selectors are evaluated once per backup against the namespace list
  at that point in time; namespaces created or relabeled mid-backup
  are not picked up.
- Backup-only for now; restore-side namespace mapping is unaffected.

Testing:
- Unit coverage in internal/resourcepolicies for validation, selector
  resolution (including AND/OR logic, case-insensitivity, and
  malformed-selector/invalid-logic error paths), and the no-match
  sentinel.
- Unit coverage in pkg/controller for the merge logic between resolved
  label selections and explicit/defaulted includes and excludes.
- End-to-end coverage in pkg/backup exercising the full backup
  pipeline with label-selected namespaces, including the
  velero.io/exclude-from-backup hard-exclusion interaction and the
  zero-match/fully-excluded sentinel path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Tiger Kaovilai <tkaovila@redhat.com>
This commit is contained in:
Tiger Kaovilai
2026-09-18 23:04:55 -04:00
co-authored by Claude Sonnet 5
parent d074047b06
commit 40af5efdd0
7 changed files with 1319 additions and 2 deletions
+178 -1
View File
@@ -223,13 +223,190 @@ type IncludeExcludePolicy struct {
ExcludedClusterScopedResources []string `yaml:"excludedClusterScopedResources"`
IncludedNamespaceScopedResources []string `yaml:"includedNamespaceScopedResources"`
ExcludedNamespaceScopedResources []string `yaml:"excludedNamespaceScopedResources"`
// IncludedNamespacesByLabel and ExcludedNamespacesByLabel are lists of Kubernetes
// label selector strings (same syntax as `kubectl get ns -l <selector>`, parsed via
// labels.Parse). At backup time, each selector is evaluated against the live namespace
// list to dynamically resolve which namespaces to include/exclude, without requiring
// namespaces to be enumerated by name in BackupSpec.
IncludedNamespacesByLabel []string `yaml:"includedNamespacesByLabel,omitempty"`
ExcludedNamespacesByLabel []string `yaml:"excludedNamespacesByLabel,omitempty"`
// LabelSelectorLogic controls how multiple entries within IncludedNamespacesByLabel are
// combined with each other, and independently how multiple entries within
// ExcludedNamespacesByLabel are combined with each other: "OR" (default) matches a
// namespace against any entry in the list; "AND" requires a namespace to match every
// entry in the list. Empty string is treated as "OR". Matching is case-insensitive
// ("and"/"Or" are accepted the same as "AND"/"OR"). This is unrelated to the
// comma-separated AND semantics within a single selector string, which is standard
// labels.Parse syntax.
LabelSelectorLogic string `yaml:"labelSelectorLogic,omitempty"`
}
func (p *IncludeExcludePolicy) Validate() error {
if err := p.validateIncludeExclude(p.IncludedClusterScopedResources, p.ExcludedClusterScopedResources); err != nil {
return err
}
return p.validateIncludeExclude(p.IncludedNamespaceScopedResources, p.ExcludedNamespaceScopedResources)
if err := p.validateIncludeExclude(p.IncludedNamespaceScopedResources, p.ExcludedNamespaceScopedResources); err != nil {
return err
}
if err := validateLabelSelectors(p.IncludedNamespacesByLabel); err != nil {
return fmt.Errorf("includedNamespacesByLabel: %w", err)
}
if err := validateLabelSelectors(p.ExcludedNamespacesByLabel); err != nil {
return fmt.Errorf("excludedNamespacesByLabel: %w", err)
}
return validateLabelSelectorLogic(p.LabelSelectorLogic)
}
// validateLabelSelectors returns an error if any selector string is empty/whitespace-only
// (which labels.Parse would otherwise silently accept as labels.Everything(), matching
// every namespace) or fails to parse as a Kubernetes label selector.
func validateLabelSelectors(selectors []string) error {
for _, s := range selectors {
if strings.TrimSpace(s) == "" {
return fmt.Errorf("label selector cannot be empty")
}
if _, err := labels.Parse(s); err != nil {
return fmt.Errorf("invalid label selector %q: %w", s, err)
}
}
return nil
}
func validateLabelSelectorLogic(logic string) error {
switch strings.ToUpper(logic) {
case "", "OR", "AND":
return nil
default:
return fmt.Errorf("labelSelectorLogic must be \"OR\" or \"AND\", got %q", logic)
}
}
// ResolveNamespacesByLabel lists all cluster namespaces and returns two independently
// resolved name sets: those matching includedSelectors, and those matching
// excludedSelectors, combined per logic ("OR": any selector in the list matches; "AND":
// every selector in the list matches; "" defaults to "OR", case-insensitive). It performs no
// cross-suppression between the two sets - the caller decides how to combine them with
// BackupSpec.IncludedNamespaces/ExcludedNamespaces. Although the production path already
// validates selectors and logic before reaching here (see validateLabelSelectors/
// validateLabelSelectorLogic, called from Validate()), this function re-validates both on
// entry since it is exported: an empty-string selector parses successfully as "match
// everything" (k8s labels.Parse("") is not an error), so skipping this check would let a
// malformed excludedSelectors entry silently exclude nothing instead of failing loudly -
// fail-open, since a namespace meant to be excluded would be backed up instead.
func ResolveNamespacesByLabel(
ctx context.Context,
client crclient.Client,
includedSelectors []string,
excludedSelectors []string,
logic string,
) ([]string, []string, error) {
if err := validateLabelSelectorLogic(logic); err != nil {
return nil, nil, err
}
if err := validateLabelSelectors(includedSelectors); err != nil {
return nil, nil, errors.Wrap(err, "includedNamespacesByLabel")
}
if err := validateLabelSelectors(excludedSelectors); err != nil {
return nil, nil, errors.Wrap(err, "excludedNamespacesByLabel")
}
nsList := &corev1api.NamespaceList{}
if err := client.List(ctx, nsList); err != nil {
return nil, nil, errors.Wrap(err, "listing namespaces")
}
matchSet := func(selectors []string) ([]string, error) {
result := sets.NewString()
if len(selectors) == 0 {
return result.List(), nil
}
// Matches validateLabelSelectorLogic's case-insensitive acceptance - "and"/"Or" etc.
// are as valid as "AND"/"OR", so the actual matching must normalize the same way.
isAND := strings.EqualFold(logic, "AND")
parsedSelectors := make([]labels.Selector, 0, len(selectors))
for _, sel := range selectors {
parsed, err := labels.Parse(sel)
if err != nil {
return nil, fmt.Errorf("invalid label selector %q: %w", sel, err)
}
parsedSelectors = append(parsedSelectors, parsed)
}
for _, ns := range nsList.Items {
nsLabels := labels.Set(ns.Labels)
if isAND {
allMatch := true
for _, parsed := range parsedSelectors {
if !parsed.Matches(nsLabels) {
allMatch = false
break
}
}
if allMatch {
result.Insert(ns.Name)
}
} else { // "OR" (default, including "")
for _, parsed := range parsedSelectors {
if parsed.Matches(nsLabels) {
result.Insert(ns.Name)
break
}
}
}
}
return result.List(), nil
}
included, err := matchSet(includedSelectors)
if err != nil {
return nil, nil, err
}
excluded, err := matchSet(excludedSelectors)
if err != nil {
return nil, nil, err
}
return included, excluded, nil
}
// NoNamespaceMatchesPattern is a namespace glob pattern guaranteed to match zero real
// namespaces - Kubernetes namespace names are RFC 1123 labels that must start and end with an
// alphanumeric character, so no real namespace can ever start with '-' - while still being
// recognized as a wildcard pattern by wildcard.ShouldExpandWildcards/ExpandWildcards.
//
// This matters because a plain empty []string in BackupSpec.IncludedNamespaces is Velero's
// long-standing "include everything" default everywhere else: wildcard.ShouldExpandWildcards
// explicitly treats len(includes)==0 as "equivalent to * (match all) - don't expand". Reusing
// that same empty representation to mean the opposite - "a configured includedNamespacesByLabel
// selector currently matches zero namespaces, so include nothing" - would silently expand to
// "back up every namespace" instead, exactly the opposite of the intended fail-safe. Routing
// through the wildcard-expansion path instead uses the mechanism
// collections.NamespaceIncludesExcludes.ShouldInclude already relies on for "include nothing":
// it returns false for everything once wildcard expansion ran and the expanded includes list
// came back empty, which only happens when the includes list contained an actual wildcard
// pattern (not a plain empty list).
//
// This must be a pattern collections.ValidateNamespaceIncludesExcludes actually accepts, not
// just wildcard.ValidateNamespaceName in isolation: that function replaces glob metacharacters
// (*, ?, [, ]) with a placeholder letter before checking the result against Kubernetes' own
// RFC 1123 namespace-name rules, so a pattern like "[A-Z]*" becomes "xA-Zxx" - the literal
// uppercase A and Z survive that substitution and fail RFC 1123 (lowercase only), even though
// wildcard.ValidateNamespaceName alone would accept it as a syntactically valid glob. "[-]*"
// substitutes to "x-xx", which is a valid RFC 1123 label, so it passes both checks - confirmed
// empirically against collections.ValidateNamespaceIncludesExcludes directly, not just reasoned
// through the substitution rule.
const NoNamespaceMatchesPattern = "[-]*"
// RepresentNamespaceSelection returns resolved as an effective IncludedNamespaces value,
// substituting NoNamespaceMatchesPattern when resolved is empty so that "the selector matched
// nothing" is represented unambiguously downstream - see NoNamespaceMatchesPattern's doc
// comment for why a plain empty slice cannot be used for this.
func RepresentNamespaceSelection(resolved []string) []string {
if len(resolved) == 0 {
return []string{NoNamespaceMatchesPattern}
}
return resolved
}
func (p *IncludeExcludePolicy) validateIncludeExclude(includesList, excludesList []string) error {
@@ -17,6 +17,7 @@ package resourcepolicies
import (
"context"
"fmt"
"testing"
"github.com/sirupsen/logrus"
@@ -27,6 +28,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/client-go/kubernetes/scheme"
crclient "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
@@ -2482,6 +2484,279 @@ includeExcludePolicy:
assert.Equal(t, []string{"ClusterRoleBinding"}, iePolicy.ExcludedClusterScopedResources)
}
func TestIncludeExcludePolicyValidateNamespacesByLabel(t *testing.T) {
tests := []struct {
name string
policy IncludeExcludePolicy
wantErr string
}{
{
name: "no label selector fields set is valid",
policy: IncludeExcludePolicy{},
},
{
name: "valid included and excluded selectors",
policy: IncludeExcludePolicy{
IncludedNamespacesByLabel: []string{"team=platform", "team=infra"},
ExcludedNamespacesByLabel: []string{"env=dev"},
},
},
{
name: "valid AND logic",
policy: IncludeExcludePolicy{
IncludedNamespacesByLabel: []string{"tier=critical", "compliance=pci"},
LabelSelectorLogic: "AND",
},
},
{
name: "empty string in includedNamespacesByLabel is rejected",
policy: IncludeExcludePolicy{
IncludedNamespacesByLabel: []string{""},
},
wantErr: "includedNamespacesByLabel: label selector cannot be empty",
},
{
name: "whitespace-only string in excludedNamespacesByLabel is rejected",
policy: IncludeExcludePolicy{
ExcludedNamespacesByLabel: []string{" "},
},
wantErr: "excludedNamespacesByLabel: label selector cannot be empty",
},
{
name: "invalid selector syntax is rejected",
policy: IncludeExcludePolicy{
IncludedNamespacesByLabel: []string{"=="},
},
wantErr: "includedNamespacesByLabel: invalid label selector",
},
{
name: "invalid operator is rejected",
policy: IncludeExcludePolicy{
ExcludedNamespacesByLabel: []string{"env >> prod"},
},
wantErr: "excludedNamespacesByLabel: invalid label selector",
},
{
name: "malformed 'in' clause without parens is rejected",
policy: IncludeExcludePolicy{
IncludedNamespacesByLabel: []string{"env in prod"},
},
wantErr: "includedNamespacesByLabel: invalid label selector",
},
{
name: "invalid labelSelectorLogic is rejected",
policy: IncludeExcludePolicy{
LabelSelectorLogic: "XOR",
},
wantErr: `labelSelectorLogic must be "OR" or "AND", got "XOR"`,
},
{
name: "lowercase labelSelectorLogic is accepted",
policy: IncludeExcludePolicy{
IncludedNamespacesByLabel: []string{"team=platform"},
LabelSelectorLogic: "and",
},
},
{
name: "mixed-case labelSelectorLogic is accepted",
policy: IncludeExcludePolicy{
IncludedNamespacesByLabel: []string{"team=platform"},
LabelSelectorLogic: "Or",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.policy.Validate()
if tc.wantErr == "" {
assert.NoError(t, err)
} else {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantErr)
}
})
}
}
func TestResolveNamespacesByLabel(t *testing.T) {
nsWith := func(name string, labels map[string]string) *corev1api.Namespace {
return &corev1api.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: name, Labels: labels},
}
}
namespaces := []crclient.Object{
nsWith("platform-prod", map[string]string{"team": "platform", "env": "prod"}),
nsWith("platform-dev", map[string]string{"team": "platform", "env": "dev"}),
nsWith("infra", map[string]string{"team": "infra"}),
nsWith("confidential", map[string]string{"confidential": "true"}),
nsWith("unlabeled", nil),
}
newClient := func() crclient.Client {
return fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(namespaces...).Build()
}
t.Run("OR logic across included selectors", func(t *testing.T) {
included, excluded, err := ResolveNamespacesByLabel(context.Background(), newClient(),
[]string{"team=platform", "team=infra"}, nil, "")
require.NoError(t, err)
assert.ElementsMatch(t, []string{"platform-prod", "platform-dev", "infra"}, included)
assert.Empty(t, excluded)
})
t.Run("AND logic across included selectors", func(t *testing.T) {
included, _, err := ResolveNamespacesByLabel(context.Background(), newClient(),
[]string{"team=platform", "env=prod"}, nil, "AND")
require.NoError(t, err)
assert.Equal(t, []string{"platform-prod"}, included)
})
t.Run("AND logic across excluded selectors", func(t *testing.T) {
// labelSelectorLogic applies independently to each list - covers the excluded half
// of the contract, not just included (which the case above already covers).
_, excluded, err := ResolveNamespacesByLabel(context.Background(), newClient(),
nil, []string{"team=platform", "env=prod"}, "AND")
require.NoError(t, err)
assert.Equal(t, []string{"platform-prod"}, excluded)
})
t.Run("AND logic matching is case-insensitive", func(t *testing.T) {
included, _, err := ResolveNamespacesByLabel(context.Background(), newClient(),
[]string{"team=platform", "env=prod"}, nil, "and")
require.NoError(t, err)
assert.Equal(t, []string{"platform-prod"}, included)
})
t.Run("excluded resolved independently of included", func(t *testing.T) {
included, excluded, err := ResolveNamespacesByLabel(context.Background(), newClient(),
nil, []string{"confidential=true"}, "")
require.NoError(t, err)
assert.Empty(t, included)
assert.Equal(t, []string{"confidential"}, excluded)
})
t.Run("configured selector matching zero namespaces returns empty, not all", func(t *testing.T) {
included, _, err := ResolveNamespacesByLabel(context.Background(), newClient(),
[]string{"team=nonexistent"}, nil, "")
require.NoError(t, err)
assert.Empty(t, included)
})
t.Run("empty selector lists return empty sets", func(t *testing.T) {
included, excluded, err := ResolveNamespacesByLabel(context.Background(), newClient(), nil, nil, "")
require.NoError(t, err)
assert.Empty(t, included)
assert.Empty(t, excluded)
})
t.Run("selector on a label key no namespace carries at all resolves to empty", func(t *testing.T) {
included, _, err := ResolveNamespacesByLabel(context.Background(), newClient(),
[]string{"nonexistent-key=anything"}, nil, "")
require.NoError(t, err)
assert.Empty(t, included)
})
t.Run("existence-check selector (!key) matches namespaces missing that label", func(t *testing.T) {
included, _, err := ResolveNamespacesByLabel(context.Background(), newClient(),
[]string{"!confidential"}, nil, "")
require.NoError(t, err)
assert.ElementsMatch(t, []string{"platform-prod", "platform-dev", "infra", "unlabeled"}, included)
})
// ResolveNamespacesByLabel is exported and does not itself call Validate() - the
// production path always validates first, but a malformed selector reaching this function
// directly must return an error, not a silent empty result. Silently treating a malformed
// *excluded* selector as "no matches" would be fail-open: a namespace meant to be excluded
// would be backed up instead.
t.Run("malformed included selector returns an error, not a silent empty result", func(t *testing.T) {
_, _, err := ResolveNamespacesByLabel(context.Background(), newClient(),
[]string{"=="}, nil, "")
require.Error(t, err)
})
t.Run("malformed excluded selector returns an error, not a silent empty result", func(t *testing.T) {
_, _, err := ResolveNamespacesByLabel(context.Background(), newClient(),
nil, []string{"=="}, "")
require.Error(t, err)
})
t.Run("empty-string included selector returns an error, not a silent match-everything", func(t *testing.T) {
// k8s labels.Parse("") succeeds and returns a selector that matches everything, so
// without validateLabelSelectors' explicit empty check, this would silently include
// every namespace instead of failing.
_, _, err := ResolveNamespacesByLabel(context.Background(), newClient(),
[]string{""}, nil, "")
require.Error(t, err)
})
t.Run("empty-string excluded selector returns an error, not a silent match-everything", func(t *testing.T) {
// Same gap as above, but fail-open for excludes: a silently-everything-matching
// excluded selector would exclude every namespace instead of failing loudly.
_, _, err := ResolveNamespacesByLabel(context.Background(), newClient(),
nil, []string{""}, "")
require.Error(t, err)
})
t.Run("invalid logic value returns an error, not a silent fall-through to OR", func(t *testing.T) {
// ResolveNamespacesByLabel is exported and does not itself call Validate() - a
// garbage logic value reaching this function directly must be rejected, not silently
// treated as OR (the exact-match comparison a garbage value would otherwise fail,
// widening an intended AND into an OR is fail-open the same way a swallowed selector
// parse error is).
_, _, err := ResolveNamespacesByLabel(context.Background(), newClient(),
[]string{"team=platform"}, nil, "XOR")
require.Error(t, err)
})
}
// TestResolveNamespacesByLabel_ManyNamespaces is a correctness-at-scale check against a
// cluster with thousands of namespaces - not a timing assertion (BenchmarkResolveNamespacesByLabel
// below covers actual performance).
func TestResolveNamespacesByLabel_ManyNamespaces(t *testing.T) {
fakeClient := manyNamespacesClient()
included, _, err := ResolveNamespacesByLabel(context.Background(), fakeClient, []string{"team=platform"}, nil, "")
require.NoError(t, err)
assert.Len(t, included, manyNamespacesMatching)
}
// manyNamespacesTotal/manyNamespacesMatching/manyNamespacesClient back both
// TestResolveNamespacesByLabel_ManyNamespaces (correctness at scale) and
// BenchmarkResolveNamespacesByLabel (`go test -bench`, not part of a normal `go test` run and
// so can't flake CI the way a fixed wall-clock assertion in a regular test can).
const (
manyNamespacesTotal = 5000
manyNamespacesMatching = 137
)
func manyNamespacesClient() crclient.Client {
objs := make([]crclient.Object, 0, manyNamespacesTotal)
for i := range manyNamespacesTotal {
nsLabels := map[string]string{"team": "other"}
if i < manyNamespacesMatching {
nsLabels = map[string]string{"team": "platform"}
}
objs = append(objs, &corev1api.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("ns-%d", i), Labels: nsLabels},
})
}
return fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(objs...).Build()
}
func BenchmarkResolveNamespacesByLabel(b *testing.B) {
fakeClient := manyNamespacesClient()
for range b.N {
if _, _, err := ResolveNamespacesByLabel(context.Background(), fakeClient, []string{"team=platform"}, nil, ""); err != nil {
b.Fatal(err)
}
}
}
func TestFirstMatchSemantics(t *testing.T) {
yamlData := `version: v1
namespacedFilterPolicies: