Merge pull request #10275 from kaovilai/namespace-selection-by-label

Implement namespace selection by label in resource policy
This commit is contained in:
lyndon-li
2026-09-22 09:22:38 +08:00
committed by GitHub
7 changed files with 1319 additions and 2 deletions
+232
View File
@@ -5548,6 +5548,32 @@ func TestBackupNamespaces(t *testing.T) {
"resources/deployments.apps/v1-preferredversion/namespaces/ns-1/deploy-1.json",
},
},
{
// Regression guard for the design/namespace-label-selector-in-resource-policy_design.md
// Precedence and Interaction trade-off: a namespace admitted into
// BackupSpec.IncludedNamespaces by name (which is exactly what
// resourcepolicies.ResolveNamespacesByLabel + prepareBackupRequest produce for
// includedNamespacesByLabel) still gets its own Namespace object backed up even
// when nothing in it matches a separately configured LabelSelector -
// namespace-selection and resource-selection-by-label are independent axes.
name: "namespace explicitly included is backed up even when nothing inside matches LabelSelector",
backup: defaultBackup().IncludedNamespaces("ns-1").
LabelSelector(&metav1.LabelSelector{MatchLabels: map[string]string{"team": "platform"}}).
Result(),
apiResources: []*test.APIResource{
test.Namespaces(
builder.ForNamespace("ns-1").Phase(corev1api.NamespaceActive).Result(),
builder.ForNamespace("ns-2").Phase(corev1api.NamespaceActive).Result(),
),
test.Deployments(
builder.ForDeployment("ns-1", "deploy-1").Result(),
),
},
want: []string{
"resources/namespaces/cluster/ns-1.json",
"resources/namespaces/v1-preferredversion/cluster/ns-1.json",
},
},
}
itemBlockPool := StartItemBlockWorkerPool(t.Context(), 1, logrus.StandardLogger())
@@ -5576,6 +5602,212 @@ func TestBackupNamespaces(t *testing.T) {
}
}
// TestBackupWithResourcePolicyNamespaceLabelSelector is an integration-style test spanning
// resourcepolicies.ResolveNamespacesByLabel and the real backup item-collection path: it
// resolves includedNamespacesByLabel against live namespaces the way prepareBackupRequest
// does, combines the result with an explicit BackupSpec.IncludedNamespaces entry the way
// mergeNamespacesByLabel's union branch does, and verifies the resulting backup contains both
// namespaces' resources - simulating a Schedule configured with both a ResourcePolicy label
// selector and an explicit include.
func TestBackupWithResourcePolicyNamespaceLabelSelector(t *testing.T) {
nsPlatform := builder.ForNamespace("platform-ns").ObjectMeta(builder.WithLabels("team", "platform")).Result()
nsOps := builder.ForNamespace("ops-ns").Result()
nsOther := builder.ForNamespace("other-ns").ObjectMeta(builder.WithLabels("team", "infra")).Result()
fakeClient := test.NewFakeControllerRuntimeClient(t, nsPlatform, nsOps, nsOther)
resolvedIncluded, _, err := resourcepolicies.ResolveNamespacesByLabel(
t.Context(), fakeClient, []string{"team=platform"}, nil, "")
require.NoError(t, err)
require.Equal(t, []string{"platform-ns"}, resolvedIncluded)
// "ops-ns" stands in for BackupSpec.IncludedNamespaces already having an explicit entry;
// mergeNamespacesByLabel would union resolvedIncluded into it additively (see
// TestMergeNamespacesByLabel in pkg/controller for that merge decision in isolation).
effectiveIncludes := append([]string{"ops-ns"}, resolvedIncluded...)
backup := defaultBackup().IncludedNamespaces(effectiveIncludes...).Result()
itemBlockPool := StartItemBlockWorkerPool(t.Context(), 1, logrus.StandardLogger())
defer itemBlockPool.Stop()
h := newHarness(t, itemBlockPool)
req := &Request{
Backup: backup,
SkippedPVTracker: NewSkipPVTracker(),
BackedUpItems: NewBackedUpItemsMap(),
WorkerPool: itemBlockPool,
}
backupFile := bytes.NewBuffer([]byte{})
h.addItems(t, test.Namespaces(
builder.ForNamespace("platform-ns").Phase(corev1api.NamespaceActive).ObjectMeta(builder.WithLabels("team", "platform")).Result(),
builder.ForNamespace("ops-ns").Phase(corev1api.NamespaceActive).Result(),
builder.ForNamespace("other-ns").Phase(corev1api.NamespaceActive).ObjectMeta(builder.WithLabels("team", "infra")).Result(),
))
h.addItems(t, test.Deployments(
builder.ForDeployment("platform-ns", "app-1").Result(),
builder.ForDeployment("ops-ns", "app-2").Result(),
builder.ForDeployment("other-ns", "app-3").Result(),
))
h.backupper.Backup(h.log, req, backupFile, nil, nil, nil)
assertTarballContents(t, backupFile,
"metadata/version",
"resources/namespaces/cluster/platform-ns.json",
"resources/namespaces/v1-preferredversion/cluster/platform-ns.json",
"resources/namespaces/cluster/ops-ns.json",
"resources/namespaces/v1-preferredversion/cluster/ops-ns.json",
"resources/deployments.apps/namespaces/platform-ns/app-1.json",
"resources/deployments.apps/v1-preferredversion/namespaces/platform-ns/app-1.json",
"resources/deployments.apps/namespaces/ops-ns/app-2.json",
"resources/deployments.apps/v1-preferredversion/namespaces/ops-ns/app-2.json",
)
}
// TestBackupResourcePolicyNamespaceLabelSelectorEdgeCases runs several scenarios through the
// real backup item-collection path (not just prepareBackupRequest's intermediate spec value),
// each constructing the same effective IncludedNamespaces/ExcludedNamespaces that
// mergeNamespacesByLabel (pkg/controller) produces for the given resource-policy configuration.
// Guards against an empty include-selector result silently expanding to "back up everything"
// downstream, and covers the explicit-wildcard and exclude-precedence handling.
func TestBackupResourcePolicyNamespaceLabelSelectorEdgeCases(t *testing.T) {
runBackup := func(t *testing.T, backup *velerov1.Backup, namespaces *test.APIResource, resources ...*test.APIResource) *bytes.Buffer {
t.Helper()
itemBlockPool := StartItemBlockWorkerPool(t.Context(), 1, logrus.StandardLogger())
defer itemBlockPool.Stop()
h := newHarness(t, itemBlockPool)
req := &Request{
Backup: backup,
SkippedPVTracker: NewSkipPVTracker(),
BackedUpItems: NewBackedUpItemsMap(),
WorkerPool: itemBlockPool,
}
backupFile := bytes.NewBuffer([]byte{})
h.addItems(t, namespaces)
for _, r := range resources {
h.addItems(t, r)
}
require.NoError(t, h.backupper.Backup(h.log, req, backupFile, nil, nil, nil))
return backupFile
}
t.Run("include selector matching zero namespaces backs up nothing, not everything", func(t *testing.T) {
// mergeNamespacesByLabel's fix: an include selector matching zero namespaces must
// resolve to resourcepolicies.NoNamespaceMatchesPattern, not a bare empty slice
// (which wildcard.ShouldExpandWildcards would otherwise treat as "match everything").
backup := defaultBackup().IncludedNamespaces(resourcepolicies.NoNamespaceMatchesPattern).Result()
backupFile := runBackup(t, backup,
test.Namespaces(
builder.ForNamespace("ns-1").Phase(corev1api.NamespaceActive).Result(),
builder.ForNamespace("ns-2").Phase(corev1api.NamespaceActive).Result(),
),
test.Deployments(builder.ForDeployment("ns-1", "app-1").Result()),
)
assertTarballContents(t, backupFile, "metadata/version")
})
t.Run("explicit wildcard plus include selector keeps everything, not narrowed", func(t *testing.T) {
// mergeNamespacesByLabel's other fix: an explicitly-configured ["*"] keeps everything
// included regardless of includedNamespacesByLabel, rather than being narrowed down to
// just the label matches. The merge canonicalizes this case back down to ["*"] (see
// TestMergeNamespacesByLabel), which is what's fed in here.
backup := defaultBackup().IncludedNamespaces("*").Result()
backupFile := runBackup(t, backup,
test.Namespaces(
builder.ForNamespace("platform-ns").Phase(corev1api.NamespaceActive).ObjectMeta(builder.WithLabels("team", "platform")).Result(),
builder.ForNamespace("other-ns").Phase(corev1api.NamespaceActive).Result(),
),
test.Deployments(
builder.ForDeployment("platform-ns", "app-1").Result(),
builder.ForDeployment("other-ns", "app-2").Result(),
),
)
assertTarballContents(t, backupFile,
"metadata/version",
"resources/namespaces/cluster/platform-ns.json",
"resources/namespaces/v1-preferredversion/cluster/platform-ns.json",
"resources/namespaces/cluster/other-ns.json",
"resources/namespaces/v1-preferredversion/cluster/other-ns.json",
"resources/deployments.apps/namespaces/platform-ns/app-1.json",
"resources/deployments.apps/v1-preferredversion/namespaces/platform-ns/app-1.json",
"resources/deployments.apps/namespaces/other-ns/app-2.json",
"resources/deployments.apps/v1-preferredversion/namespaces/other-ns/app-2.json",
)
})
t.Run("namespace matching both included and excluded label selectors is excluded", func(t *testing.T) {
// A namespace resolved into both resolvedIncluded and resolvedExcluded - exclusion
// wins, same as BackupSpec.ExcludedNamespaces vs IncludedNamespaces always has.
backup := defaultBackup().
IncludedNamespaces("both-ns", "include-only-ns").
ExcludedNamespaces("both-ns").
Result()
backupFile := runBackup(t, backup, test.Namespaces(
builder.ForNamespace("both-ns").Phase(corev1api.NamespaceActive).Result(),
builder.ForNamespace("include-only-ns").Phase(corev1api.NamespaceActive).Result(),
))
assertTarballContents(t, backupFile,
"metadata/version",
"resources/namespaces/cluster/include-only-ns.json",
"resources/namespaces/v1-preferredversion/cluster/include-only-ns.json",
)
})
t.Run("velero.io/exclude-from-backup hard exclusion wins over an include-label match", func(t *testing.T) {
// prepareBackupRequest's ordering guarantee: hard-excluded namespaces are already in
// ExcludedNamespaces by the time includedNamespacesByLabel resolution runs, so a
// namespace that also matches an include selector must still end up excluded.
backup := defaultBackup().
IncludedNamespaces("hard-excluded-ns", "platform-ns").
ExcludedNamespaces("hard-excluded-ns").
Result()
backupFile := runBackup(t, backup, test.Namespaces(
builder.ForNamespace("hard-excluded-ns").Phase(corev1api.NamespaceActive).
ObjectMeta(builder.WithLabels("velero.io/exclude-from-backup", "true")).Result(),
builder.ForNamespace("platform-ns").Phase(corev1api.NamespaceActive).Result(),
))
assertTarballContents(t, backupFile,
"metadata/version",
"resources/namespaces/cluster/platform-ns.json",
"resources/namespaces/v1-preferredversion/cluster/platform-ns.json",
)
})
t.Run("every included namespace also excluded backs up nothing, not everything", func(t *testing.T) {
// mergeNamespacesByLabel's exclude-subtraction step (added to satisfy
// collections.ValidateIncludesExcludes' invariants) can itself empty out the
// included set when every included name is also excluded. When that happens, the
// merge must emit resourcepolicies.NoNamespaceMatchesPattern rather than a bare
// empty include list, which wildcard.ShouldExpandWildcards would otherwise treat as
// "match everything" - the same hazard the zero-match sentinel exists for, reached
// through a different path.
backup := defaultBackup().
IncludedNamespaces(resourcepolicies.NoNamespaceMatchesPattern).
ExcludedNamespaces("only-ns").
Result()
backupFile := runBackup(t, backup, test.Namespaces(
builder.ForNamespace("only-ns").Phase(corev1api.NamespaceActive).Result(),
))
assertTarballContents(t, backupFile, "metadata/version")
})
}
func TestUpdateVolumeInfos(t *testing.T) {
timeExample := time.Date(2014, 6, 5, 11, 56, 45, 0, time.Local)
now := metav1.NewTime(timeExample)
+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}