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
+126 -1
View File
@@ -603,7 +603,12 @@ func (b *backupReconciler) prepareBackupRequest(ctx context.Context, backup *vel
// Empty IncludedNamespaces means "include all namespaces". Normalize
// to ["*"] so that downstream wildcard expansion does not collapse
// an empty-includes + wildcard-excludes combination into "back up nothing".
if len(request.Spec.IncludedNamespaces) == 0 {
// Recorded separately from the normalized value below: once normalized, an
// originally-empty list and an explicitly-configured ["*"] are indistinguishable,
// but mergeNamespacesByLabel's replace-vs-union decision needs to tell them apart
// (see its doc comment).
includedNamespacesWereDefaulted := len(request.Spec.IncludedNamespaces) == 0
if includedNamespacesWereDefaulted {
request.Spec.IncludedNamespaces = []string{"*"}
}
@@ -636,10 +641,130 @@ func (b *backupReconciler) prepareBackupRequest(ctx context.Context, backup *vel
request.Status.ValidationErrors = append(request.Status.ValidationErrors, "include-resources, exclude-resources and include-cluster-resources are old filter parameters.\n"+
"They cannot be used with namespace-scoped or fine-grained global filter policies.")
}
// Resolve includedNamespacesByLabel/excludedNamespacesByLabel from the resource policy
// (if configured) against the live namespace list, and merge into the effective
// namespace filter. Must run after the velero.io/exclude-from-backup hard-exclusion
// above, so that the "- BackupSpec.ExcludedNamespaces" term below already carries
// hard-excluded namespaces.
if resourcePolicies != nil && resourcePolicies.GetIncludeExcludePolicy() != nil {
iep := resourcePolicies.GetIncludeExcludePolicy()
if len(iep.IncludedNamespacesByLabel) > 0 || len(iep.ExcludedNamespacesByLabel) > 0 {
resolvedIncluded, resolvedExcluded, err := resourcepolicies.ResolveNamespacesByLabel(
ctx, b.kbClient,
iep.IncludedNamespacesByLabel, iep.ExcludedNamespacesByLabel, iep.LabelSelectorLogic)
if err != nil {
request.Status.ValidationErrors = append(request.Status.ValidationErrors, fmt.Sprintf("error resolving namespace label selectors: %v", err))
} else {
logger.WithFields(logrus.Fields{
"includedNamespacesByLabelCount": len(resolvedIncluded),
"excludedNamespacesByLabelCount": len(resolvedExcluded),
}).Info("resolved namespaces by label selector")
logger.WithFields(logrus.Fields{
"includedNamespacesByLabel": resolvedIncluded,
"excludedNamespacesByLabel": resolvedExcluded,
}).Debug("resolved namespaces by label selector detail")
request.Spec.IncludedNamespaces, request.Spec.ExcludedNamespaces = mergeNamespacesByLabel(
request.Spec.IncludedNamespaces,
request.Spec.ExcludedNamespaces,
len(iep.IncludedNamespacesByLabel) > 0,
includedNamespacesWereDefaulted,
resolvedIncluded,
resolvedExcluded,
)
}
}
}
request.ResPolicies = resourcePolicies
return request
}
// mergeNamespacesByLabel merges a resource policy's resolved includedNamespacesByLabel/
// excludedNamespacesByLabel name sets into the backup's effective IncludedNamespaces/
// ExcludedNamespaces, per the design's Precedence and Interaction rules:
//
// - includedNamespaces is assumed already normalized so that an originally-empty
// BackupSpec.IncludedNamespaces reads as the ["*"] wildcard (prepareBackupRequest does
// this normalization earlier, before resource-policy processing runs).
// - includedNamespacesWereDefaulted reports whether that normalization actually fired -
// i.e. whether BackupSpec.IncludedNamespaces was originally empty, as opposed to the user
// having explicitly written ["*"] themselves. The two are indistinguishable by the time
// includedNamespaces reaches this function (both read as ["*"]), so the caller must track
// and pass this separately; inspecting includedNamespaces alone would wrongly narrow an
// explicit ["*"] down to only the label matches instead of leaving it as "everything".
// - labelIncludeActive reports whether includedNamespacesByLabel was *configured* at all
// (not whether it matched anything - a configured selector matching zero namespaces must
// still produce an empty-selection baseline, not fall through to "all namespaces").
// - When labelIncludeActive and includedNamespacesWereDefaulted, resolvedIncluded REPLACES
// the wildcard baseline (unioning into "all" would still be "all", defeating the feature's
// primary use case of a schedule with no explicit includes) - represented via
// resourcepolicies.RepresentNamespaceSelection so a zero-match result is expressed as a
// wildcard pattern guaranteed to match nothing, not a plain empty slice (which
// wildcard.ShouldExpandWildcards treats as "match everything" - see that function's doc
// comment for why a bare empty list cannot be reused to mean the opposite here). When
// explicit concrete names were already present, resolvedIncluded is unioned in additively
// instead. When the user explicitly wrote ["*"] themselves (includedNamespacesWereDefaulted
// is false but includedNamespaces is already ["*"]), unioning concrete names into it is a
// no-op at match time (IncludesExcludes.ShouldInclude treats a "*" entry as match-everything
// regardless of what else is in the list) but would also violate
// collections.ValidateIncludesExcludes' "'*' must be alone in includes" invariant if the
// merged result were ever re-validated - so this case canonicalizes back down to ["*"]
// instead of widening it.
// - resolvedExcluded is unioned into excludedNamespaces whenever non-empty, regardless of
// labelIncludeActive - excludedNamespacesByLabel is purely subtractive, same role as
// BackupSpec.ExcludedNamespaces today. An empty resolvedExcluded needs no such translation:
// "exclude nothing" is unambiguous as a plain empty list, unlike "include nothing".
// - Finally, unless mergedIncluded is the wildcard, any name present in both merged lists is
// dropped from mergedIncluded (not mergedExcluded) so the two stay mutually exclusive.
// Exclusion already wins over inclusion at match time regardless (same ShouldInclude
// precedence as above), so this changes only the returned representation, not resolved
// backup behavior - it keeps the merged lists satisfying
// collections.ValidateIncludesExcludes' "excludes list cannot contain an item in the
// includes list" invariant too, for the same reason the "*" case above is canonicalized
// rather than left as an invariant-violating pair.
func mergeNamespacesByLabel(
includedNamespaces []string,
excludedNamespaces []string,
labelIncludeActive bool,
includedNamespacesWereDefaulted bool,
resolvedIncluded []string,
resolvedExcluded []string,
) (mergedIncluded []string, mergedExcluded []string) {
mergedIncluded = includedNamespaces
if labelIncludeActive {
switch {
case includedNamespacesWereDefaulted:
mergedIncluded = resourcepolicies.RepresentNamespaceSelection(resolvedIncluded)
case sets.NewString(includedNamespaces...).Has("*"):
mergedIncluded = []string{"*"}
default:
mergedIncluded = sets.NewString(includedNamespaces...).Insert(resolvedIncluded...).List()
}
}
mergedExcluded = excludedNamespaces
if len(resolvedExcluded) > 0 {
mergedExcluded = sets.NewString(excludedNamespaces...).Insert(resolvedExcluded...).List()
}
if !sets.NewString(mergedIncluded...).Has("*") {
// Difference can legitimately empty this out entirely (every resolved or explicit
// include also landed in mergedExcluded) - route back through
// RepresentNamespaceSelection so that comes back as the no-match sentinel, not a bare
// empty slice. The same "empty means include everything" hazard that motivated the
// zero-match sentinel above applies here too: an empty result at this point means
// "everything that was included is now excluded", i.e. include nothing, and a plain
// empty []string would be silently reinterpreted downstream as the opposite.
mergedIncluded = resourcepolicies.RepresentNamespaceSelection(
sets.NewString(mergedIncluded...).Difference(sets.NewString(mergedExcluded...)).List(),
)
}
return mergedIncluded, mergedExcluded
}
// validateAndGetSnapshotLocations gets a collection of VolumeSnapshotLocation objects that
// this backup will use (returned as a map of provider name -> VSL), and ensures:
// - each location name in .spec.volumeSnapshotLocations exists as a location
+411
View File
@@ -354,6 +354,417 @@ func TestPrepareBackupRequest_EmptyIncludedNamespacesNormalizedToWildcard(t *tes
assert.Equal(t, []string{"*"}, res.Spec.IncludedNamespaces)
}
// TestPrepareBackupRequest_IncludedNamespacesByLabel_ReplacesWildcardBaseline verifies that
// when includedNamespacesByLabel is configured and BackupSpec.IncludedNamespaces was left
// empty (normalized to the ["*"] wildcard), the label-resolved namespace set REPLACES the
// wildcard baseline rather than being unioned into it - otherwise "all" unioned with anything
// is still "all", defeating the feature.
func TestPrepareBackupRequest_IncludedNamespacesByLabel_ReplacesWildcardBaseline(t *testing.T) {
formatFlag := logging.FormatText
logger := logging.DefaultLogger(logrus.DebugLevel, formatFlag)
policyYAML := `version: v1
includeExcludePolicy:
includedNamespacesByLabel:
- "team=platform"
`
policyConfigMap := &corev1api.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "ns-label-policy", Namespace: velerov1api.DefaultNamespace},
Data: map[string]string{"policy": policyYAML},
}
backupLocation := builder.ForBackupStorageLocation("velero", "loc-1").Phase(velerov1api.BackupStorageLocationPhaseAvailable).Result()
nsPlatform := builder.ForNamespace("platform-ns").ObjectMeta(builder.WithLabels("team", "platform")).Result()
nsOther := builder.ForNamespace("other-ns").ObjectMeta(builder.WithLabels("team", "infra")).Result()
fakeClient := velerotest.NewFakeControllerRuntimeClient(t, policyConfigMap, backupLocation, nsPlatform, nsOther)
apiServer := velerotest.NewAPIServer(t)
discoveryHelper, err := discovery.NewHelper(apiServer.DiscoveryClient, logger)
require.NoError(t, err)
c := &backupReconciler{
discoveryHelper: discoveryHelper,
kbClient: fakeClient,
defaultBackupLocation: backupLocation.Name,
clock: &clock.RealClock{},
formatFlag: formatFlag,
}
backup := defaultBackup().Result()
backup.Spec.IncludedNamespaces = nil
backup.Spec.ResourcePolicy = &corev1api.TypedLocalObjectReference{Kind: "configmap", Name: "ns-label-policy"}
res := c.prepareBackupRequest(ctx, backup, logger)
defer res.WorkerPool.Stop()
assert.Empty(t, res.Status.ValidationErrors)
assert.Equal(t, []string{"platform-ns"}, res.Spec.IncludedNamespaces)
}
// TestPrepareBackupRequest_IncludedNamespacesByLabel_UnionsWithExplicitIncludes verifies that
// when BackupSpec.IncludedNamespaces already has explicit names, the label-resolved set is
// additive (unioned), not a replacement.
func TestPrepareBackupRequest_IncludedNamespacesByLabel_UnionsWithExplicitIncludes(t *testing.T) {
formatFlag := logging.FormatText
logger := logging.DefaultLogger(logrus.DebugLevel, formatFlag)
policyYAML := `version: v1
includeExcludePolicy:
includedNamespacesByLabel:
- "team=platform"
`
policyConfigMap := &corev1api.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "ns-label-policy", Namespace: velerov1api.DefaultNamespace},
Data: map[string]string{"policy": policyYAML},
}
backupLocation := builder.ForBackupStorageLocation("velero", "loc-1").Phase(velerov1api.BackupStorageLocationPhaseAvailable).Result()
nsPlatform := builder.ForNamespace("platform-ns").ObjectMeta(builder.WithLabels("team", "platform")).Result()
fakeClient := velerotest.NewFakeControllerRuntimeClient(t, policyConfigMap, backupLocation, nsPlatform)
apiServer := velerotest.NewAPIServer(t)
discoveryHelper, err := discovery.NewHelper(apiServer.DiscoveryClient, logger)
require.NoError(t, err)
c := &backupReconciler{
discoveryHelper: discoveryHelper,
kbClient: fakeClient,
defaultBackupLocation: backupLocation.Name,
clock: &clock.RealClock{},
formatFlag: formatFlag,
}
backup := defaultBackup().IncludedNamespaces("explicit-ns").Result()
backup.Spec.ResourcePolicy = &corev1api.TypedLocalObjectReference{Kind: "configmap", Name: "ns-label-policy"}
res := c.prepareBackupRequest(ctx, backup, logger)
defer res.WorkerPool.Stop()
assert.Empty(t, res.Status.ValidationErrors)
assert.ElementsMatch(t, []string{"explicit-ns", "platform-ns"}, res.Spec.IncludedNamespaces)
}
// TestPrepareBackupRequest_IncludedNamespacesByLabel_ZeroMatchesResolvesToNoMatchSentinel
// verifies that a configured includedNamespacesByLabel selector matching zero namespaces
// resolves to resourcepolicies.NoNamespaceMatchesPattern, not a fall-through to "all
// namespaces" - this is deliberate fail-safe behavior, not a bug.
func TestPrepareBackupRequest_IncludedNamespacesByLabel_ZeroMatchesResolvesToNoMatchSentinel(t *testing.T) {
formatFlag := logging.FormatText
logger := logging.DefaultLogger(logrus.DebugLevel, formatFlag)
policyYAML := `version: v1
includeExcludePolicy:
includedNamespacesByLabel:
- "team=nonexistent"
`
policyConfigMap := &corev1api.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "ns-label-policy", Namespace: velerov1api.DefaultNamespace},
Data: map[string]string{"policy": policyYAML},
}
backupLocation := builder.ForBackupStorageLocation("velero", "loc-1").Phase(velerov1api.BackupStorageLocationPhaseAvailable).Result()
nsOther := builder.ForNamespace("other-ns").ObjectMeta(builder.WithLabels("team", "infra")).Result()
fakeClient := velerotest.NewFakeControllerRuntimeClient(t, policyConfigMap, backupLocation, nsOther)
apiServer := velerotest.NewAPIServer(t)
discoveryHelper, err := discovery.NewHelper(apiServer.DiscoveryClient, logger)
require.NoError(t, err)
c := &backupReconciler{
discoveryHelper: discoveryHelper,
kbClient: fakeClient,
defaultBackupLocation: backupLocation.Name,
clock: &clock.RealClock{},
formatFlag: formatFlag,
}
backup := defaultBackup().Result()
backup.Spec.IncludedNamespaces = nil
backup.Spec.ResourcePolicy = &corev1api.TypedLocalObjectReference{Kind: "configmap", Name: "ns-label-policy"}
res := c.prepareBackupRequest(ctx, backup, logger)
defer res.WorkerPool.Stop()
assert.Empty(t, res.Status.ValidationErrors)
// Not a plain empty slice - see resourcepolicies.NoNamespaceMatchesPattern's doc comment
// for why a bare empty list here would be silently reinterpreted downstream as "include
// everything" instead of the intended "include nothing".
assert.Equal(t, []string{resourcepolicies.NoNamespaceMatchesPattern}, res.Spec.IncludedNamespaces)
}
// TestPrepareBackupRequest_IncludedNamespacesByLabel_ExplicitWildcardCanonicalized verifies
// that when BackupSpec.IncludedNamespaces was explicitly set to ["*"] (as opposed to left
// empty and normalized to ["*"]), includedNamespacesByLabel does not narrow the backup down
// to only the label matches - the two must not be conflated (see mergeNamespacesByLabel's doc
// comment). The result stays canonicalized to ["*"] rather than widened to ["*", "platform-ns"]:
// both are equivalent at match time, but only the former satisfies
// collections.ValidateIncludesExcludes' "'*' must be alone in includes" invariant.
func TestPrepareBackupRequest_IncludedNamespacesByLabel_ExplicitWildcardCanonicalized(t *testing.T) {
formatFlag := logging.FormatText
logger := logging.DefaultLogger(logrus.DebugLevel, formatFlag)
policyYAML := `version: v1
includeExcludePolicy:
includedNamespacesByLabel:
- "team=platform"
`
policyConfigMap := &corev1api.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "ns-label-policy", Namespace: velerov1api.DefaultNamespace},
Data: map[string]string{"policy": policyYAML},
}
backupLocation := builder.ForBackupStorageLocation("velero", "loc-1").Phase(velerov1api.BackupStorageLocationPhaseAvailable).Result()
nsPlatform := builder.ForNamespace("platform-ns").ObjectMeta(builder.WithLabels("team", "platform")).Result()
fakeClient := velerotest.NewFakeControllerRuntimeClient(t, policyConfigMap, backupLocation, nsPlatform)
apiServer := velerotest.NewAPIServer(t)
discoveryHelper, err := discovery.NewHelper(apiServer.DiscoveryClient, logger)
require.NoError(t, err)
c := &backupReconciler{
discoveryHelper: discoveryHelper,
kbClient: fakeClient,
defaultBackupLocation: backupLocation.Name,
clock: &clock.RealClock{},
formatFlag: formatFlag,
}
backup := defaultBackup().IncludedNamespaces("*").Result()
backup.Spec.ResourcePolicy = &corev1api.TypedLocalObjectReference{Kind: "configmap", Name: "ns-label-policy"}
res := c.prepareBackupRequest(ctx, backup, logger)
defer res.WorkerPool.Stop()
assert.Empty(t, res.Status.ValidationErrors)
assert.ElementsMatch(t, []string{"*"}, res.Spec.IncludedNamespaces)
}
// TestPrepareBackupRequest_ExcludedNamespacesByLabel_Subtracted verifies excludedNamespacesByLabel
// resolves independently and is merged into BackupSpec.ExcludedNamespaces, regardless of whether
// includedNamespacesByLabel is configured.
func TestPrepareBackupRequest_ExcludedNamespacesByLabel_Subtracted(t *testing.T) {
formatFlag := logging.FormatText
logger := logging.DefaultLogger(logrus.DebugLevel, formatFlag)
policyYAML := `version: v1
includeExcludePolicy:
excludedNamespacesByLabel:
- "confidential=true"
`
policyConfigMap := &corev1api.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "ns-label-policy", Namespace: velerov1api.DefaultNamespace},
Data: map[string]string{"policy": policyYAML},
}
backupLocation := builder.ForBackupStorageLocation("velero", "loc-1").Phase(velerov1api.BackupStorageLocationPhaseAvailable).Result()
nsConfidential := builder.ForNamespace("secret-ns").ObjectMeta(builder.WithLabels("confidential", "true")).Result()
fakeClient := velerotest.NewFakeControllerRuntimeClient(t, policyConfigMap, backupLocation, nsConfidential)
apiServer := velerotest.NewAPIServer(t)
discoveryHelper, err := discovery.NewHelper(apiServer.DiscoveryClient, logger)
require.NoError(t, err)
c := &backupReconciler{
discoveryHelper: discoveryHelper,
kbClient: fakeClient,
defaultBackupLocation: backupLocation.Name,
clock: &clock.RealClock{},
formatFlag: formatFlag,
}
backup := defaultBackup().Result()
backup.Spec.IncludedNamespaces = nil
backup.Spec.ResourcePolicy = &corev1api.TypedLocalObjectReference{Kind: "configmap", Name: "ns-label-policy"}
res := c.prepareBackupRequest(ctx, backup, logger)
defer res.WorkerPool.Stop()
assert.Empty(t, res.Status.ValidationErrors)
// includedNamespacesByLabel not configured, so baseline stays the wildcard.
assert.Equal(t, []string{"*"}, res.Spec.IncludedNamespaces)
assert.Equal(t, []string{"secret-ns"}, res.Spec.ExcludedNamespaces)
}
// TestMergeNamespacesByLabel exercises the union/replacement decision directly, without
// the full prepareBackupRequest scaffold (fake client, discovery helper, resource policy
// ConfigMap, etc.) - see mergeNamespacesByLabel's doc comment for the precedence rules.
func TestMergeNamespacesByLabel(t *testing.T) {
tests := []struct {
name string
includedNamespaces []string
excludedNamespaces []string
labelIncludeActive bool
includedNamespacesWereDefaulted bool
resolvedIncluded []string
resolvedExcluded []string
wantIncluded []string
wantExcluded []string
}{
{
name: "defaulted wildcard baseline is replaced",
includedNamespaces: []string{"*"},
labelIncludeActive: true,
includedNamespacesWereDefaulted: true,
resolvedIncluded: []string{"platform-ns"},
wantIncluded: []string{"platform-ns"},
wantExcluded: nil,
},
{
name: "explicit includes union additively",
includedNamespaces: []string{"explicit-ns"},
labelIncludeActive: true,
includedNamespacesWereDefaulted: false,
resolvedIncluded: []string{"platform-ns"},
wantIncluded: []string{"explicit-ns", "platform-ns"},
wantExcluded: nil,
},
{
// Explicit includes can themselves be a non-"*" wildcard glob (e.g. from
// --include-namespaces 'app-*'), not just concrete names - the union branch must
// pass that through unchanged alongside the resolved concrete names; downstream
// wildcard.ShouldExpandWildcards still expands it normally since it isn't the bare
// "*" special case.
name: "explicit glob-pattern include unions with resolved names unchanged",
includedNamespaces: []string{"app-*"},
labelIncludeActive: true,
includedNamespacesWereDefaulted: false,
resolvedIncluded: []string{"platform-ns"},
wantIncluded: []string{"app-*", "platform-ns"},
wantExcluded: nil,
},
{
// A bare empty []string here would be interpreted downstream by
// wildcard.ShouldExpandWildcards as "match everything" (its own documented
// behavior), silently defeating the fail-safe. Must come back as
// resourcepolicies.NoNamespaceMatchesPattern instead - see
// mergeNamespacesByLabel's doc comment.
name: "defaulted wildcard matching zero namespaces resolves to the no-match sentinel, not empty",
includedNamespaces: []string{"*"},
labelIncludeActive: true,
includedNamespacesWereDefaulted: true,
resolvedIncluded: nil,
wantIncluded: []string{resourcepolicies.NoNamespaceMatchesPattern},
wantExcluded: nil,
},
{
// The code must not re-derive "was this defaulted" by checking
// includedNamespaces == ["*"], since an explicitly-configured wildcard looks
// identical to the normalized default by this point. An explicit ["*"] must stay
// "everything", never narrow to just the label matches - canonicalized back to
// ["*"] rather than widened to ["*", "platform-ns"], since the latter is
// semantically identical at match time but would violate
// collections.ValidateIncludesExcludes' "'*' must be alone in includes" invariant.
name: "explicitly-configured wildcard canonicalizes to itself instead of widening",
includedNamespaces: []string{"*"},
labelIncludeActive: true,
includedNamespacesWereDefaulted: false,
resolvedIncluded: []string{"platform-ns"},
wantIncluded: []string{"*"},
wantExcluded: nil,
},
{
name: "explicitly-configured wildcard stays everything even on zero matches",
includedNamespaces: []string{"*"},
labelIncludeActive: true,
includedNamespacesWereDefaulted: false,
resolvedIncluded: nil,
wantIncluded: []string{"*"},
wantExcluded: nil,
},
{
name: "not labelIncludeActive leaves includedNamespaces untouched",
includedNamespaces: []string{"*"},
labelIncludeActive: false,
includedNamespacesWereDefaulted: true,
resolvedIncluded: []string{"platform-ns"}, // should be ignored
wantIncluded: []string{"*"},
wantExcluded: nil,
},
{
name: "resolvedExcluded unions in regardless of labelIncludeActive",
includedNamespaces: []string{"*"},
excludedNamespaces: []string{"legacy-ns"},
labelIncludeActive: false,
includedNamespacesWereDefaulted: true,
resolvedExcluded: []string{"secret-ns"},
wantIncluded: []string{"*"},
wantExcluded: []string{"legacy-ns", "secret-ns"},
},
{
name: "empty resolvedExcluded leaves excludedNamespaces untouched",
includedNamespaces: []string{"*"},
excludedNamespaces: []string{"legacy-ns"},
labelIncludeActive: false,
includedNamespacesWereDefaulted: true,
resolvedExcluded: nil,
wantIncluded: []string{"*"},
wantExcluded: []string{"legacy-ns"},
},
{
// A resolved-include name overlapping an already-excluded name violates
// collections.ValidateIncludesExcludes' "excludes list cannot contain an item in
// the includes list" invariant if the merged result were ever re-validated.
// Exclusion already wins at match time regardless (IncludesExcludes.ShouldInclude
// checks excludes first), so dropping the overlap from mergedIncluded changes only
// the returned representation, not resolved backup behavior.
name: "a resolved include overlapping an existing exclude is dropped from the merged includes",
includedNamespaces: []string{"explicit-ns"},
excludedNamespaces: []string{"both-ns"},
labelIncludeActive: true,
includedNamespacesWereDefaulted: false,
resolvedIncluded: []string{"both-ns", "platform-ns"},
wantIncluded: []string{"explicit-ns", "platform-ns"},
wantExcluded: []string{"both-ns"},
},
{
// The exclude-subtraction step itself can produce a bare empty []string when
// every explicit include is also excluded - the same "empty means include
// everything" hazard the zero-match sentinel exists for, reached through a
// different path.
name: "an explicit include fully removed by exclusion resolves to the no-match sentinel, not empty",
includedNamespaces: []string{"explicit-ns"},
labelIncludeActive: true,
includedNamespacesWereDefaulted: false,
resolvedExcluded: []string{"explicit-ns"},
wantIncluded: []string{resourcepolicies.NoNamespaceMatchesPattern},
wantExcluded: []string{"explicit-ns"},
},
{
// Same hazard, but for a defaulted-wildcard-replaced resolved include (rather
// than an explicit one) that a same-namespace excludedNamespacesByLabel match
// fully removes.
name: "a resolved include fully removed by exclusion resolves to the no-match sentinel, not empty",
includedNamespaces: []string{"*"},
labelIncludeActive: true,
includedNamespacesWereDefaulted: true,
resolvedIncluded: []string{"both-ns"},
resolvedExcluded: []string{"both-ns"},
wantIncluded: []string{resourcepolicies.NoNamespaceMatchesPattern},
wantExcluded: []string{"both-ns"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gotIncluded, gotExcluded := mergeNamespacesByLabel(
tc.includedNamespaces,
tc.excludedNamespaces,
tc.labelIncludeActive,
tc.includedNamespacesWereDefaulted,
tc.resolvedIncluded,
tc.resolvedExcluded,
)
assert.ElementsMatch(t, tc.wantIncluded, gotIncluded)
assert.ElementsMatch(t, tc.wantExcluded, gotExcluded)
})
}
}
func Test_prepareBackupRequest_BackupStorageLocation(t *testing.T) {
var (
defaultBackupTTL = metav1.Duration{Duration: 24 * 30 * time.Hour}