diff --git a/changelogs/unreleased/10011-adam-jian-zhang b/changelogs/unreleased/10011-adam-jian-zhang
new file mode 100644
index 000000000..e34dead3e
--- /dev/null
+++ b/changelogs/unreleased/10011-adam-jian-zhang
@@ -0,0 +1 @@
+Cherry pick fine grained filters PRs: #9783, #9821, #9840, #9847, #9848, #9880, #9881, #9908
diff --git a/changelogs/unreleased/9821-adam-jian-zhang b/changelogs/unreleased/9821-adam-jian-zhang
new file mode 100644
index 000000000..4cbdcdf20
--- /dev/null
+++ b/changelogs/unreleased/9821-adam-jian-zhang
@@ -0,0 +1 @@
+Fix issue #9811, add interface to support ClusterScopedFilterPolicy and NamespacedFilterPolicy
diff --git a/changelogs/unreleased/9840-adam-jian-zhang b/changelogs/unreleased/9840-adam-jian-zhang
new file mode 100644
index 000000000..8758c8251
--- /dev/null
+++ b/changelogs/unreleased/9840-adam-jian-zhang
@@ -0,0 +1 @@
+Fix issue #9812, validate ClusterScopedFilterPolicy and NamespacedFilterPolicy incompatible with legacy filters
diff --git a/changelogs/unreleased/9847-adam-jian-zhang b/changelogs/unreleased/9847-adam-jian-zhang
new file mode 100644
index 000000000..82de39ca0
--- /dev/null
+++ b/changelogs/unreleased/9847-adam-jian-zhang
@@ -0,0 +1 @@
+Fix issue #9813, add validations for ClusterScopedFilterPolicy
diff --git a/changelogs/unreleased/9848-adam-jian-zhang b/changelogs/unreleased/9848-adam-jian-zhang
new file mode 100644
index 000000000..499c41fa5
--- /dev/null
+++ b/changelogs/unreleased/9848-adam-jian-zhang
@@ -0,0 +1 @@
+Fix issue #9814, add validations for NamespacedFilterPolicies
diff --git a/changelogs/unreleased/9880-adam-jian-zhang b/changelogs/unreleased/9880-adam-jian-zhang
new file mode 100644
index 000000000..010def90c
--- /dev/null
+++ b/changelogs/unreleased/9880-adam-jian-zhang
@@ -0,0 +1 @@
+Fix issue #9815, implement core logic of backup with ClusterScopedFilterPolicy and NamespacedFilterPolicies
diff --git a/changelogs/unreleased/9881-adam-jian-zhang b/changelogs/unreleased/9881-adam-jian-zhang
new file mode 100644
index 000000000..8c37c6062
--- /dev/null
+++ b/changelogs/unreleased/9881-adam-jian-zhang
@@ -0,0 +1 @@
+Fix issue #9816, add cli support for backup with ClusterScopedFilterPolicy and NamespacedFilterPolicies
diff --git a/changelogs/unreleased/9908-adam-jian-zhang b/changelogs/unreleased/9908-adam-jian-zhang
new file mode 100644
index 000000000..2ba573609
--- /dev/null
+++ b/changelogs/unreleased/9908-adam-jian-zhang
@@ -0,0 +1 @@
+Fix issue #9907, add cache for the GetNamespaceFilter call
diff --git a/design/backup-filter-enhancement/fine-grained-backup-filters-design.md b/design/backup-filter-enhancement/fine-grained-backup-filters-design.md
new file mode 100644
index 000000000..0bb52ffd5
--- /dev/null
+++ b/design/backup-filter-enhancement/fine-grained-backup-filters-design.md
@@ -0,0 +1,839 @@
+# Fine Grained Backup Filters via Resource Policies
+
+## Glossary & Abbreviation
+
+**Backup Filter**: The mechanism in Velero that determines which Kubernetes resources are collected from the cluster and written into the backup archive. Backup filters currently operate on four dimensions: namespace, resource type, label, and cluster scope.
+**Global Filter**: A filter that applies uniformly across all namespaces in a backup. All existing Velero backup filters are global filters.
+**Namespace-Scoped Filter**: A filter that applies only within specific namespaces, overriding the global filter for those namespaces. This is the capability introduced by this design.
+**ClusterScopedFilterPolicy**: A global filter for cluster-scoped resources that allows per-kind label selectors and name patterns, functioning similarly to `NamespacedFilterPolicy` but applied to cluster-scoped resources globally.
+**Resource Filter**: A filter rule that pairs one or more resource kinds with their own label selector and/or name patterns. Multiple resource filters within a namespace-scoped policy allow different filtering criteria for different resource types.
+**Resource Name Filter**: A filter that matches individual resource instances by their metadata.name, using glob patterns. This is a new filter dimension introduced by this design.
+**Resource Policy**: An existing Velero mechanism where backup behavior rules are defined in a ConfigMap and referenced from `BackupSpec.ResourcePolicy`. Currently used for volume policies and global include/exclude policies.
+
+## Background
+
+Velero's backup filter system allows users to specify which resources to include or exclude from a backup. The filters operate on three dimensions:
+
+1. **Namespace** — `IncludedNamespaces`/`ExcludedNamespaces` select which namespaces to back up
+2. **Resource Type** — `IncludedResources`/`ExcludedResources` (or the newer scoped variants `Included/ExcludedClusterScopedResources`, `Included/ExcludedNamespaceScopedResources`) select which Kubernetes resource types to back up
+3. **Labels** — `LabelSelector`/`OrLabelSelectors` filter individual objects by their labels
+
+All three dimensions are applied **globally** — the same resource type filter, the same label selector, and the same namespace list apply uniformly throughout the entire backup operation. Specifically:
+
+- In `item_collector.go`, the `ResourceIncludesExcludes.ShouldInclude()` check is a single global check applied to every resource type across all namespaces.
+- In `listResourceByLabelsPerNamespace()`, the same `LabelSelector` is passed to every Kubernetes API list call regardless of namespace.
+- There is no mechanism to filter resources by their individual `metadata.name`.
+
+This creates three critical gaps for common backup scenarios:
+
+**Gap 1: Different resource needs per namespace.** When multiple applications share the same cluster, different namespaces often require different backup strategies. For example, a namespace running a database workload may need all resource types backed up, while a namespace running a stateless frontend may only need Deployments, ConfigMaps, and Services. Setting `IncludedResources: [configmaps]` means *all* ConfigMaps in *all* included namespaces — you cannot say "only ConfigMaps in namespace-a but everything in namespace-b."
+
+**Gap 2: Same resource type, different workloads.** Resources of the same type (e.g., ConfigMaps or Secrets) in the same namespace may belong to different workloads. For instance, a namespace may contain `app-config`, `app-secret`, `monitoring-config`, and `monitoring-secret`. Without name-based filtering, you cannot selectively back up only the `app-*` resources — the only option is label-based selection, which requires workloads to have been pre-labeled appropriately.
+
+**Gap 3: Different kinds need different selectors.** Within a single namespace, different resource types may belong to different workloads with different labels. For example, Deployments labeled `app=workload-1` and StatefulSets labeled `app=workload-2` in the same namespace. The current single-label-selector-per-namespace model cannot express this — the label selector applies identically to all resource types.
+
+## Goals
+
+- Extend the `ResourcePolicies` ConfigMap format with a `namespacedFilterPolicies` section that allows per-namespace, per-kind resource filtering with independent label selectors and name patterns for each resource type
+- Extend the `ResourcePolicies` ConfigMap format with a `clusterScopedFilterPolicy` section that allows per-kind resource filtering with independent label selectors and name patterns for cluster-scoped resources globally
+- Support resource name filtering by glob patterns using the same `gobwas/glob` library that Velero uses for namespace patterns, ensuring consistency across the codebase
+- Support per-kind label selectors, so that different resource types within the same namespace can be filtered with different labels
+- Maintain full backward compatibility — existing backups with no `namespacedFilterPolicies` behave exactly as they do today
+- Define clear precedence rules for how per-namespace filters interact with global filters
+- Add corresponding validation within the Resource Policies validation pipeline using existing Velero wildcard validation functions
+- Update `velero backup describe` output to display per-namespace filter information when present
+- Ensure the restore process works correctly with backups produced by namespace-scoped filters, without requiring restore-side code changes in the initial phase
+
+## Non-Goals
+
+- Adding namespace-scoped filters to `RestoreSpec` or the restore pipeline is not part of the initial implementation. Restore from a namespace-filtered backup works automatically because the restore process reads whatever is in the backup archive. Restore-side namespace filters will be addressed in a follow-up.
+- Changing existing `BackupSpec` fields (`IncludedResources`, `LabelSelector`, etc.) or adding new CRD fields is explicitly avoided by this design.
+- Supporting regex patterns for resource names is not included. Glob patterns (already used throughout Velero) are sufficient and consistent.
+- Modifying the plugin `ResourceSelector` system (`AppliesTo()` / `resolvedAction.ShouldUse()`) is not part of this design.
+- CLI flags for inline specification of namespace-scoped filters are not part of the initial implementation. The configuration is expressed in the ResourcePolicy ConfigMap YAML.
+
+## Architecture of Namespace-Scoped Filters
+
+### Configuration Model
+
+The namespace-scoped filters and fine-grained global filters are defined in the same ResourcePolicy ConfigMap that is already referenced by `BackupSpec.ResourcePolicy`. The YAML format is extended with two new top-level keys: `clusterScopedFilterPolicy` and `namespacedFilterPolicies`
+
+```yaml
+version: v1
+volumePolicies:
+ # existing volume policies (unchanged)
+ - conditions:
+ capacity: "0,100Gi"
+ action:
+ type: skip
+includeExcludePolicy:
+ # existing global include/exclude policy (unchanged)
+ includedNamespaceScopedResources:
+ - configmaps
+clusterScopedFilterPolicy:
+ # NEW: global overrides for cluster-scoped resources
+ resourceFilters:
+ - kinds: [ClusterRole, ClusterRoleBinding]
+ names: ["my-app-*"]
+ - kinds: [CustomResourceDefinition]
+ labelSelector:
+ app: my-app
+namespacedFilterPolicies:
+ # NEW: per-namespace filter overrides
+ - namespaces:
+ - ns-a
+ resourceFilters:
+ - kinds: [ConfigMap, Secret, Deployment]
+ labelSelector:
+ app: my-app
+ - namespaces:
+ - ns-b
+ resourceFilters:
+ - kinds: [Deployment]
+ names: [app-1, app-2]
+ - kinds: [ConfigMap]
+ labelSelector:
+ app: my-service
+```
+
+All four sections coexist in the same ConfigMap. They are independent — `volumePolicies` handles volume backup strategy, `includeExcludePolicy` handles global resource type filtering, `clusterScopedFilterPolicy` handles cluster-scoped resource filtering by kind/name/label, and `namespacedFilterPolicies` handles per-namespace, per-kind overrides.
+
+### The `resourceFilters` Model
+
+Each `namespacedFilterPolicies` entry targets one or more namespaces and contains a `resourceFilters` array. Each entry in `resourceFilters` pairs one or more resource kinds with their own label selector and name patterns:
+
+```yaml
+namespacedFilterPolicies:
+ - namespaces: [ns-a]
+ resourceFilters:
+ - kinds: [ConfigMap, Secret] # these kinds share a selector
+ labelSelector: {app: my-app}
+ names: ["app-*"]
+ - kinds: [Deployment] # this kind has its own selector
+ names: [workload-1, workload-2]
+ - kinds: [StatefulSet] # this kind has no extra filtering
+```
+
+This model has one way to express filters — there is no ambiguity about how to structure the configuration. Only resource kinds listed in `resourceFilters` entries are included in the backup for the matched namespaces; unlisted kinds are implicitly excluded.
+
+#### Catch-All Resource Filter (Empty `kinds` or `["*"]`)
+
+A `ResourceFilter` entry with an empty (or omitted) `kinds` field, or a field explicitly set to `["*"]`, acts as a **catch-all**. Its `labelSelector` or `orLabelSelectors` (if provided) is applied to **all resource types in the namespace that are not already matched by a kind-specific filter entry**. If no selectors are provided, all unlisted resources are included. Using `["*"]` is highly recommended as it makes the catch-all intention explicit and self-documenting.
+
+**Rules for catch-all entries:**
+- At most **one** catch-all entry is allowed per `NamespacedFilterPolicy`.
+- `names` and `excludedNames` are **not** supported on catch-all entries. Name patterns are kind-specific by nature and cannot be applied across arbitrary kinds; use kind-specific entries for name-based filtering.
+- The catch-all applies to kinds that are **not listed in any other `resourceFilters` entry** in the same policy. Kind-specific entries take precedence over the catch-all.
+- A catch-all entry **does not inherit or fall back to `BackupSpec.LabelSelector`**. If a catch-all entry has no `labelSelector`/`orLabelSelectors`, all unlisted resource kinds in the namespace are included with **no label filtering** — the global label selector is not applied. Define a catch-all with an explicit `labelSelector` if label-based filtering is desired for unlisted kinds.
+
+**Evaluation order within a namespace filter policy:**
+1. For each resource kind encountered during backup, the system first checks whether a kind-specific `resourceFilters` entry exists for that kind.
+2. If a kind-specific entry exists, it is used exclusively (its label selectors and name patterns apply).
+3. If no kind-specific entry exists but a catch-all entry is present, the catch-all's `labelSelector`/`orLabelSelectors` is applied to that kind.
+4. If neither a kind-specific entry nor a catch-all entry exists, the kind is excluded from the backup for that namespace.
+
+### Filter Precedence Model
+
+The namespace-scoped filter system and fine-grained global filter system layer on top of the existing global filter system. They intentionally behave differently:
+- **`namespacedFilterPolicies`** acts as an **exclusive allowlist (boundary)**. Only kinds explicitly listed (or matched by a catch-all) are backed up from that namespace. This gives namespace owners complete and isolated control over their namespace's backup contents, preventing unexpected data spillage from global fallbacks.
+- **`clusterScopedFilterPolicy`** acts as a **refinement overlay (tweak)**. Unlisted cluster-scoped kinds fall back to the standard global filters. This allows administrators to selectively adjust filtering for a few specific cluster-scoped kinds without rewriting the entire global inclusion list.
+
+**For Namespace-Scoped Resources:**
+
+The evaluation order is:
+
+1. **Global namespace filter** (`BackupSpec.IncludedNamespaces`/`ExcludedNamespaces`) is checked first. A namespace must pass this filter to be considered at all. `namespacedFilterPolicies` cannot override namespace exclusion — if a namespace is excluded globally, no filter policy entry can bring it back.
+
+2. **Per-namespace filter lookup.** For each namespace that passes the global namespace filter, the system checks whether any `namespacedFilterPolicies` entry matches (by namespace name or glob pattern). If a match is found, the `resourceFilters` array determines what gets backed up for that namespace:
+ - Only resource kinds listed in `resourceFilters[].kinds` are included
+ - Each kind uses its own `labelSelector`/`orLabelSelectors` (if specified)
+ - Each kind uses its own `names`/`excludedNames` patterns (if specified)
+
+3. **Namespaces without a matching filter policy** continue to use the global filters (`BackupSpec.IncludedResources`, `BackupSpec.LabelSelector`, etc., combined with `includeExcludePolicy`) exactly as they do today.
+
+4. **If multiple filter policy entries could match the same namespace** (e.g., `team-*` and `team-frontend-*` both matching `team-frontend-prod`), the **first matching policy in the list** is used. **Important: Place more specific patterns before broader patterns** to achieve the intended filtering behavior.
+
+5. **The `velero.io/exclude-from-backup=true` label** always takes precedence over all filters, regardless of whether the item matches global or per-namespace filters.
+
+6. **Interaction with `includeExcludePolicy`**: `namespacedFilterPolicies` is a **refinement** of the global resource filter system, not a replacement. Global exclusions defined in `includeExcludePolicy` (e.g., `excludedNamespaceScopedResources: [secrets]`) are applied first at the resource-type level before per-namespace filter policies are consulted. A namespace-scoped filter policy cannot re-include a resource kind that has been globally excluded by `includeExcludePolicy`. For example, if `secrets` is listed under `excludedNamespaceScopedResources`, no `Secret` resources will be backed up from any namespace, even if a `namespacedFilterPolicies` entry explicitly lists `Secret` for that namespace. Users who need per-namespace secret selection must remove `secrets` from the global exclusion list.
+
+ To help users catch this misconfiguration early, Velero logs a warning at backup start when a `namespacedFilterPolicies` entry lists a kind that is globally excluded by `includeExcludePolicy`:
+ ```
+ level=warn msg="namespacedFilterPolicies entry lists a kind that is globally excluded by includeExcludePolicy; the per-namespace filter entry has no effect" kind="secrets" namespacePattern="ns-a"
+ ```
+
+**For Cluster-Scoped Resources:**
+
+1. If `clusterScopedFilterPolicy` is present, it acts as a **refinement overlay** over the existing global filters for cluster-scoped resources. It is NOT an exclusive allowlist.
+ - To back up cluster-scoped resources in a namespace-filtered backup, you must still explicitly include them via `BackupSpec.IncludedClusterScopedResources`.
+ - If a cluster-scoped kind is listed in its `resourceFilters`, its specific `labelSelector`/`orLabelSelectors` and `names`/`excludedNames` patterns are applied.
+ - If a cluster-scoped kind is **not listed**, it falls back to the standard global filters (`BackupSpec.LabelSelector`, etc.) and is included in the backup.
+
+2. If `clusterScopedFilterPolicy` is absent, Velero falls back to the existing global filters (`IncludedClusterScopedResources`, `IncludedResources`, `LabelSelector`, etc.) for cluster-scoped resources.
+
+3. **The `velero.io/exclude-from-backup=true` label** always takes precedence over all filters.
+
+```mermaid
+flowchart TD
+ A["BackupSpec Global
IncludedNamespaces / ExcludedNamespaces"]
+ B{Namespace passes
global filter?}
+ C[Namespace excluded
from backup]
+ D{namespacedFilterPolicies
lookup by namespace}
+ E{"For each resource kind:
is kind in resourceFilters?"}
+ F["Apply kind-specific filters:
- labelSelector / orLabelSelectors
- names / excludedNames"]
+ G[Kind skipped for
this namespace]
+ H["Use global filters:
- BackupSpec IncludedResources
- BackupSpec LabelSelector
- includeExcludePolicy"]
+
+ I{"Is resource
cluster-scoped?"}
+ J{"Is clusterScopedFilterPolicy
present?"}
+ K{"Is kind in resourceFilters?"}
+ L["Apply kind-specific filters:
- labelSelector / orLabelSelectors
- names / excludedNames"]
+
+ I -- Yes --> J
+ J -- Yes --> K
+ K -- Yes --> L
+ K -- No --> H
+ J -- No --> H
+
+ I -- No --> A
+ A --> B
+ B -- No --> C
+ B -- Yes --> D
+ D -- Match found --> E
+ E -- Yes --> F
+ E -- No --> G
+ D -- No match found --> H
+```
+
+### Data Flow in the Backup Pipeline
+
+The existing backup pipeline has two stages: item collection and item backup. Namespace-scoped filters and fine-grained global filters are applied at both stages:
+
+**Stage 1 — Item Collection (`item_collector.go`).** Resources are listed from the Kubernetes API.
+
+- **Resource type check** in `getResourceItems()`: Before iterating namespaces, the global resource type check still applies.
+ - **For Cluster-Scoped Resources:** The global resource type check (`ShouldInclude`) determines if the kind is collected. `ClusterScopedFilterPolicy` does not skip unlisted cluster-scoped kinds at this stage.
+ - **For Namespace-Scoped Resources:** Within the namespace loop, a per-namespace resource type check is added. If a filter policy matches the current namespace, only resource kinds listed in `resourceFilters[].kinds` are included — if the current resource type is not listed, it is skipped for that namespace.
+- **Label selector** in `listResourceByLabelsPerNamespace()` and `listResourceByLabelsGlobally()`: The function looks up the filter policy (either the namespace-specific one or the fine-grained global one). If found, it retrieves the `ResourceFilter` entry for the current resource kind and uses that entry's `labelSelector`/`orLabelSelectors` for the Kubernetes API list call. If no filter policy is found, the global selectors are used as before.
+
+**Stage 2 — Item Backup (`item_backupper.go`).** Collected items are validated and written to the archive.
+
+- **Name pattern check** in `itemInclusionChecks()`: After the existing namespace and resource type re-validation, the item's `metadata.name` is checked against the `ResourceFilter` entry's `names`/`excludedNames` glob patterns for the item's kind (checking the cluster-scoped map for cluster resources and namespace map for namespace resources). If the name doesn't match, the item is excluded.
+ - **Important:** If the item's kind is not listed in the namespace filter map **and** there is no catch-all entry, the item passes through Stage 2 without a name check. This is intentional — see [Plugin AdditionalItems and Auto-Backed Up CRDs](#edge-cases-and-behavior-documentation) below.
+
+### Impact on Restore
+
+The restore process (`pkg/restore/restore.go`) is **not modified** in this design. The reason:
+
+- Restore reads the backup archive as-is. Items excluded by namespace-scoped filter policies during backup are simply absent from the archive. The restore process iterates what's in the tarball and applies `RestoreSpec` filters on top. No items excluded during backup will appear during restore.
+- Restore plugins that request "additional items" (via `RestoreItemAction`) may reference items excluded from the backup. These items won't be in the archive, so the restore will skip them silently. This is the same behavior that occurs today with any incomplete backup — no new risk is introduced.
+- Users can still use `RestoreSpec.IncludedNamespaces` to selectively restore from a namespace-filtered backup.
+
+A follow-up design will add namespace-scoped filters to the restore pipeline.
+
+### Edge Cases and Behavior Documentation
+
+This section documents the system behavior in edge cases and error conditions:
+
+**Plugin AdditionalItems and Auto-Backed Up CRDs:**
+Cluster-scoped resources injected dynamically (such as `VolumeSnapshotClass` from the CSI plugin or `CustomResourceDefinition` from Velero's auto-backup loop) do not require hardcoded exceptions. In `itemInclusionChecks()`, Velero natively allows unlisted cluster-scoped resources to pass through unless explicitly excluded by the user. `ClusterScopedFilterPolicy` preserves this permissive behavior: if a dynamically injected cluster-scoped resource is NOT listed in the policy, it passes through untouched. If it IS listed, its specific `names` and `excludedNames` filters are strictly enforced.
+
+**Plugin-injected namespace-scoped additional items** follow the same permissive principle. When a `BackupItemAction` returns additional items whose kind is not listed in the matched `namespacedFilterPolicies` entry and there is no catch-all entry, those items still pass through Stage 2 (`itemInclusionChecks`). This is intentional: blocking plugin-injected items at Stage 2 would break backup completeness — for example, a CSI plugin may inject a `VolumeSnapshotContent` that is required for a correct restore even when the user's filter policy only lists application resource types.
+
+The kind-level exclusion that makes `namespacedFilterPolicies` an exclusive allowlist applies only during **Stage 1** (the primary collection pass in `item_collector.go`). At Stage 2, `itemInclusionChecks` enforces only:
+- The `velero.io/exclude-from-backup=true` label (always takes precedence).
+- The `names`/`excludedNames` patterns for **listed** kinds.
+
+Plugin-injected items of unlisted kinds are therefore included as long as they are not explicitly excluded by label. Users who need to suppress a specific plugin-injected kind should apply the `velero.io/exclude-from-backup=true` label to those resources.
+
+**Multiple Glob Patterns Matching Same Namespace (Incorrect Order):**
+```yaml
+namespacedFilterPolicies:
+ - namespaces: ["team-*"] # Broader pattern listed first
+ resourceFilters:
+ - kinds: [Deployment, Service]
+ - namespaces: ["team-frontend-*"] # More specific pattern listed second
+ resourceFilters:
+ - kinds: [ConfigMap, Secret, Deployment, Service]
+```
+**Behavior:** For namespace `team-frontend-prod`, the broader `team-*` pattern matches first, so only `Deployment` and `Service` are backed up. The more specific `team-frontend-*` rule is never reached.
+
+**Multiple Glob Patterns Matching Same Namespace (Correct Order):**
+```yaml
+namespacedFilterPolicies:
+ - namespaces: ["team-frontend-*"] # More specific pattern listed first
+ resourceFilters:
+ - kinds: [ConfigMap, Secret, Deployment, Service]
+ - namespaces: ["team-*"] # Broader pattern listed second
+ resourceFilters:
+ - kinds: [Deployment, Service]
+```
+**Behavior:** For namespace `team-frontend-prod`, the specific `team-frontend-*` pattern matches first, backing up all specified resources. For `team-backend-dev`, the broader `team-*` pattern matches, backing up only `Deployment` and `Service`. This achieves the intended behavior.
+
+**Namespace Included Globally But No Matching Filter Policy:**
+```yaml
+# BackupSpec includes "production" namespace
+# ResourcePolicy has no namespacedFilterPolicies entry for "production"
+```
+**Behavior:** The namespace uses global filters exactly as it does today. This is the backward compatibility behavior — only namespaces with explicit filter policies get namespace-scoped filtering.
+
+
+**Empty ResourceFilters Array:**
+```yaml
+namespacedFilterPolicies:
+ - namespaces: ["test-namespace"]
+ resourceFilters: [] # empty array
+```
+**Behavior:** Validation error during backup creation:
+```
+namespacedFilterPolicies[0]: at least one resourceFilter must be specified
+```
+
+**Namespace Pattern with No Matches:**
+```yaml
+namespacedFilterPolicies:
+ - namespaces: ["nonexistent-*"]
+ resourceFilters: [...]
+```
+**Behavior:** No error. The filter policy is loaded but never applied since no namespaces match the pattern. This allows for conditional filtering based on namespace existence.
+
+**Resource Kind Not Present in Target Namespaces:**
+```yaml
+resourceFilters:
+ - kinds: ["StatefulSet"] # namespace has no StatefulSets
+ names: ["workload-1"]
+```
+**Behavior:** No error. The filter is applied but finds no matching resources. Empty result set is valid.
+
+**Conflicting Name Patterns:**
+```yaml
+resourceFilters:
+ - kinds: ["ConfigMap"]
+ names: ["app-*"]
+ excludedNames: ["app-config"] # conflicts with names pattern
+```
+**Behavior:** The `excludedNames` takes precedence. Resources matching `app-*` are included, then `app-config` is excluded. Net result: includes `app-secret`, `app-data`, etc., but excludes `app-config`.
+
+**Invalid Label Selector Syntax:**
+```yaml
+resourceFilters:
+ - kinds: ["Pod"]
+ labelSelector:
+ "invalid label key!": "value" # invalid key syntax
+```
+**Behavior:** Validation error during backup creation when `labels.SelectorFromSet()` fails:
+```
+namespacedFilterPolicies[0].resourceFilters[0]: invalid label selector: "invalid label key!" is not a valid label key
+```
+
+**Out-of-Scope Kinds in Filter Entries:**
+A user may accidentally list a cluster-scoped kind (e.g., `ClusterRole`) inside a `namespacedFilterPolicies` entry, or a namespace-scoped kind (e.g., `ConfigMap`) inside `clusterScopedFilterPolicy`. The system silently ignores such entries at the Kubernetes API level — namespace-scoped items are never listed globally, and cluster-scoped items are never listed per-namespace, so no matching resources will ever be found. A warning is logged at backup start to help the user detect the misconfiguration. No validation error is raised — the entry is harmless but ineffective.
+
+**Discovery Helper Unavailable:**
+If the discovery helper is completely unavailable during backup initialization, the backup fails with:
+```
+failed to resolve namespace filter policies: discovery client unavailable
+```
+This is consistent with how other discovery-dependent features handle this error condition.
+
+# Detailed Design
+
+## ResourceFilter Field Notes
+
+**`labelSelector`** supports equality-based selectors only (`key=value`). Set-based requirements (e.g., `environment in (prod, staging)`) are not supported. To match resources with any of several label combinations, use `orLabelSelectors` with multiple maps — each map is AND-evaluated internally, and the maps are OR-evaluated across the list. `labelSelector` and `orLabelSelectors` cannot co-exist in the same entry.
+
+**`names` / `excludedNames`** accept exact resource names or glob patterns. If `names` is empty, all resource names are included (subject to label filters). `excludedNames` takes precedence over `names` when a name matches both.
+
+## Validation
+
+**Validation functions for `namespacedFilterPolicies`:**
+
+1. **Each filter policy must specify at least one namespace:**
+ ```
+ namespacedFilterPolicies[N]: at least one namespace must be specified
+ ```
+
+2. **Each filter policy must specify at least one resource filter:**
+ ```
+ namespacedFilterPolicies[N]: at least one resourceFilter must be specified
+ ```
+
+3. **Each resource filter without kinds can only be defined once, and cannot specify names/excludedNames.**
+ ```
+ namespacedFilterPolicies[N]: only one resource filter with empty kinds is allowed
+ namespacedFilterPolicies[N].resourceFilters[M]: names or excludedNames cannot be specified when kinds is empty
+ ```
+
+4. **No duplicate kinds across resource filter entries** within the same namespace filter:
+ ```
+ namespacedFilterPolicies[N]: kind "Pod" appears in both resourceFilters[0] and resourceFilters[2]
+ ```
+
+5. **`labelSelector` and `orLabelSelectors` mutual exclusion** within each resource filter:
+ ```
+ namespacedFilterPolicies[N].resourceFilters[M]: labelSelector and orLabelSelectors cannot co-exist
+ ```
+
+6. **No duplicate namespace patterns across filter policies.** This validates only exact duplicates - runtime behavior handles overlapping patterns.
+
+ **Rationale:** Detecting all possible pattern overlaps (like `team-*` vs `team-frontend-*`) is computationally complex and may reject valid configurations. Instead, the runtime uses first-match semantics - the first matching filter policy in the list is applied. This allows users flexibility while preventing obvious configuration errors.
+
+7. **Namespace patterns must be valid globs.**
+
+8. **Resource name patterns must be valid globs.**
+
+9. **Resource kind validation with discovery helper** (performed during backup initialization).
+
+**Validation functions for `clusterScopedFilterPolicy`:**
+
+1. **At least one resourceFilter must be specified.**
+ ```
+ clusterScopedFilterPolicy: at least one resourceFilter must be specified
+ ```
+
+2. **No duplicate kinds across resource filters.**
+
+3. **`labelSelector` and `orLabelSelectors` mutual exclusion.**
+
+4. **Resource name patterns must be valid globs.**
+
+Additionally, in `backup_controller.go`, a validation check ensures that `namespacedFilterPolicies` and `clusterScopedFilterPolicy` are not used with old-style resource filters (`IncludedResources`/`ExcludedResources`/`IncludeClusterResources`), similar to the existing check for `includeExcludePolicy`.
+
+## ConfigMap Examples
+
+### Per-Namespace Resource Type Filtering
+
+Back up only ConfigMaps, Secrets, and Deployments (with label `app=my-app`) from `ns-a`, but everything from `ns-b`:
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: backup-filter-policy
+ namespace: velero
+data:
+ policy: |
+ version: v1
+ namespacedFilterPolicies:
+ - namespaces:
+ - ns-a
+ resourceFilters:
+ - kinds: [ConfigMap, Secret, Deployment]
+ labelSelector:
+ app: my-app
+ # ns-b has no filter policy entry, so global filters apply (include everything)
+```
+
+Backup CR referencing it:
+
+```yaml
+apiVersion: velero.io/v1
+kind: Backup
+metadata:
+ name: selective-backup
+ namespace: velero
+spec:
+ includedNamespaces:
+ - ns-a
+ - ns-b
+ resourcePolicy:
+ kind: configmap
+ name: backup-filter-policy
+ storageLocation: default
+ ttl: 720h0m0s
+```
+
+### Per-Kind Label Selectors (Different Labels per Kind)
+
+Back up Deployments with one label and StatefulSets with a different label from the same namespace:
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: vm-filter-policy
+ namespace: velero
+data:
+ policy: |
+ version: v1
+ namespacedFilterPolicies:
+ - namespaces:
+ - target-namespace
+ resourceFilters:
+ - kinds: [Deployment]
+ labelSelector:
+ app: production-workload-1
+ - kinds: [StatefulSet]
+ labelSelector:
+ app: production-workload-2
+```
+
+### Per-Kind Exact Names
+
+Back up specific Deployments, Configmaps, and Secrets by name:
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: named-resource-filter
+ namespace: velero
+data:
+ policy: |
+ version: v1
+ namespacedFilterPolicies:
+ - namespaces:
+ - target-namespace
+ resourceFilters:
+ - kinds: [Deployment]
+ names: [workload-1, workload-2]
+ - kinds: [ConfigMap]
+ names: [p1, p2]
+ - kinds: [Secret]
+ names: [c1, c2]
+```
+
+### Name Pattern Filtering with Exclusion
+
+Back up only `app-*` ConfigMaps and Secrets from `production`, excluding temporary and debug resources:
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config-filter-policy
+ namespace: velero
+data:
+ policy: |
+ version: v1
+ namespacedFilterPolicies:
+ - namespaces:
+ - production
+ resourceFilters:
+ - kinds: [ConfigMap, Secret]
+ names: ["app-*"]
+ excludedNames: ["*-tmp", "*-debug"]
+```
+
+### Catch-All with No Label Selector (Override-Only)
+
+A user may want to use the global configuration for 99% of resources in a namespace, but only apply a specific name filter to a single kind. To achieve this without explicitly listing all other kinds or adding dummy labels, a catch-all filter without a label selector can be used:
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: override-only-filter-policy
+ namespace: velero
+data:
+ policy: |
+ version: v1
+ namespacedFilterPolicies:
+ - namespaces:
+ - ns-a
+ resourceFilters:
+ - kinds: [Secret]
+ names: [my-secret] # Specific override for Secrets
+ - kinds: ["*"] # Catch-all: NO label selector
+ # Includes all other kinds unconditionally
+```
+
+**Result:**
+- `Secret` resources: only `my-secret` is backed up.
+- All other resource types: backed up unconditionally (acting like a global fallback).
+
+### Catch-All Label Selector (Back Up Everything with a Specific Label)
+
+When a user wants to back up any resource type in a namespace that carries a particular label — without enumerating every kind — the catch-all entry (empty `kinds` or `["*"]`) achieves this with a single rule:
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: label-based-filter-policy
+ namespace: velero
+data:
+ policy: |
+ version: v1
+ namespacedFilterPolicies:
+ - namespaces:
+ - production
+ resourceFilters:
+ - kinds: ["*"] # catch-all: applies to every kind not listed below
+ labelSelector:
+ backup: "true" # back up any resource carrying this label
+```
+
+**Result:** Every resource type in `production` that has the label `backup=true` is backed up. Resources without that label are excluded. No kind enumeration is required.
+
+### Catch-All with Per-Kind Name Overrides
+
+A more advanced pattern: use exact names for specific kinds and fall back to a label selector for all remaining kinds. Kind-specific entries take precedence over the catch-all:
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: mixed-filter-policy
+ namespace: velero
+data:
+ policy: |
+ version: v1
+ namespacedFilterPolicies:
+ - namespaces:
+ - production
+ resourceFilters:
+ - kinds: [Deployment]
+ names: [api-server, worker] # these exact Deployments by name
+ - kinds: [Secret]
+ names: [db-credentials, tls-cert] # these exact Secrets by name
+ - kinds: ["*"] # catch-all for all other kinds
+ labelSelector:
+ backup: "true" # back up by label
+```
+
+**Result:**
+- `Deployment` resources: only `api-server` and `worker` are backed up (name filter; the catch-all does not apply).
+- `Secret` resources: only `db-credentials` and `tls-cert` are backed up (name filter; the catch-all does not apply).
+- All other resource types (ConfigMap, StatefulSet, Service, etc.): backed up only if they carry `backup=true`.
+
+This pattern is useful when certain high-value resources need precise name-based selection, while the rest of the namespace is covered by a label convention.
+
+### Glob Namespace Patterns and Ordering
+
+Apply filters to namespaces matching patterns. **Critical: Order patterns from most specific to least specific:**
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: team-filter-policy
+ namespace: velero
+data:
+ policy: |
+ version: v1
+ namespacedFilterPolicies:
+ # More specific patterns first
+ - namespaces:
+ - "team-frontend-prod" # Most specific (exact match)
+ resourceFilters:
+ - kinds: [Deployment, Service, ConfigMap, Secret, PVC]
+ - namespaces:
+ - "team-frontend-*" # Less specific (pattern match)
+ resourceFilters:
+ - kinds: [Deployment, Service, ConfigMap]
+ - namespaces:
+ - "team-*" # Least specific (broad pattern)
+ resourceFilters:
+ - kinds: [Deployment, Service]
+```
+
+**Pattern Matching Results:**
+- `team-frontend-prod` → Uses exact match policy (backs up 5 resource types)
+- `team-frontend-dev` → Uses `team-frontend-*` policy (backs up 3 resource types)
+- `team-backend-test` → Uses `team-*` policy (backs up 2 resource types)
+- `app-namespace` → No match, uses global filters
+
+### Combined with Volume Policies
+
+Both volume policies and namespace-scoped filters in the same ConfigMap:
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: combined-policy
+ namespace: velero
+data:
+ policy: |
+ version: v1
+ volumePolicies:
+ - conditions:
+ capacity: "0,10Gi"
+ storageClass:
+ - standard
+ action:
+ type: fs-backup
+ - conditions:
+ capacity: "10Gi,100Gi"
+ action:
+ type: snapshot
+ namespacedFilterPolicies:
+ - namespaces:
+ - production
+ resourceFilters:
+ - kinds: [Deployment]
+ names: [workload-1, workload-2]
+ - kinds: [StatefulSet]
+ labelSelector:
+ app: my-app
+ - kinds: [ConfigMap, Secret]
+ names: ["app-*"]
+ excludedNames: ["*-tmp", "*-debug"]
+```
+
+### Backup CR — No ResourcePolicy (backward compatible)
+
+Existing backups continue to work exactly as before:
+
+```yaml
+apiVersion: velero.io/v1
+kind: Backup
+metadata:
+ name: full-backup
+ namespace: velero
+spec:
+ includedNamespaces:
+ - "*"
+ includedResources:
+ - "*"
+ labelSelector:
+ matchLabels:
+ backup: "true"
+ storageLocation: default
+```
+
+## CLI
+
+### `velero backup describe`
+
+The output is extended to display namespace-scoped filter policies when present in the ResourcePolicy ConfigMap:
+
+```
+Name: selective-backup
+Namespace: velero
+Labels:
+Annotations:
+
+Phase: Completed
+
+Errors: 0
+Warnings: 0
+
+Namespaces:
+ Included: ns-a, ns-b
+ Excluded:
+
+Resources:
+ Included: *
+ Excluded:
+ Cluster-scoped: auto
+
+Label selector:
+
+Resource Policy: backup-filter-policy
+
+Namespace-Scoped Filter Policies:
+ ns-a:
+ Resource Filters:
+ ConfigMap, Secret, Deployment:
+ Label selector: app=my-app
+ Included names:
+ Excluded names:
+ target-namespace:
+ Resource Filters:
+ Deployment:
+ Label selector: app=production-workload-1
+ Included names:
+ Excluded names:
+ StatefulSet:
+ Label selector: app=production-workload-2
+ Included names:
+ Excluded names:
+ production:
+ Resource Filters:
+ Deployment:
+ Label selector:
+ Included names: [api-server, worker]
+ Excluded names:
+ (all other kinds):
+ Label selector: backup=true
+ Included names:
+ Excluded names:
+
+Fine-Grained Global Filter Policy:
+ Resource Filters:
+ ClusterRole, ClusterRoleBinding:
+ Label selector:
+ Included names: [my-app-*]
+ Excluded names:
+ CustomResourceDefinition:
+ Label selector: app=my-app
+ Included names:
+ Excluded names:
+
+Storage Location: default
+
+...
+```
+
+### `velero backup create`
+
+No new CLI flags are added. The namespace-scoped filter policies are specified in the ResourcePolicy ConfigMap, which is already referenced via the existing `--resource-policies-configmap` flag:
+
+```bash
+velero backup create selective-backup \
+ --include-namespaces ns-a,ns-b \
+ --resource-policies-configmap backup-filter-policy
+```
+
+The `--help` output for `velero backup create` is updated to clarify the interaction between global and namespace-scoped filters:
+
+```
+Backup Filtering Options:
+ --include-namespaces stringArray namespaces to include in the backup (use '*' for all namespaces)
+ --exclude-namespaces stringArray namespaces to exclude from the backup
+ --include-resources stringArray resources to include in the backup, formatted as resource.group
+ --exclude-resources stringArray resources to exclude from the backup, formatted as resource.group
+ --include-cluster-resources optionalBool[=true] include cluster-scoped resources
+ --exclude-cluster-resources exclude cluster-scoped resources
+ --selector labelSelector only back up resources matching this label selector
+ --or-selector labelSelector back up resources matching any of the label selectors (can be repeated)
+ --resource-policies-configmap string reference to a configmap containing resource policies for volume snapshots and namespace-scoped filtering
+
+Notes:
+- Global filters (--include-resources, --selector, etc.) apply to all included namespaces
+- Namespace-scoped filters defined in --resource-policies-configmap override global filters for matching namespaces
+- Fine-grained global filter policies defined in --resource-policies-configmap override global filters for cluster-scoped resources
+- Use 'velero backup describe' to view resolved filter policies after backup creation
+```
+
+### CLI Integration Points
+
+**Backup Creation Workflow:**
+1. User creates ResourcePolicy ConfigMap with `namespacedFilterPolicies`
+2. User references ConfigMap via `--resource-policies-configmap` flag
+3. Backup controller validates policies during backup initialization
+4. Validation errors are reported immediately with specific line/field references
+
+**Help and Discovery:**
+- `velero backup create --help` includes updated filtering documentation
+- `velero backup describe` shows resolved filter policies for troubleshooting
+- Validation errors include ConfigMap field references for easy debugging
+
+**Configuration Discovery:**
+- `velero backup create --help` includes namespace-scoped filtering documentation
+- `velero backup describe` shows resolved filter policies for verification
+
+## User Perspective
+
+This design provides fine-grained, per-namespace, per-kind control over backup filtering. Key user-facing aspects:
+
+- **For users not using namespace-scoped filter policies**: Zero changes. All existing backups and workflows continue to work identically. The new YAML key is optional.
+- **For users adopting namespace-scoped filter policies**: Create a ConfigMap with the `namespacedFilterPolicies` section and reference it via `BackupSpec.ResourcePolicy` (or the existing `--resource-policies-configmap` flag). The backup will selectively include/exclude resources per namespace based on the filter rules.
+- **For users already using ResourcePolicy for volume policies**: Add the `namespacedFilterPolicies` section to the same ConfigMap. Both volume policies and namespace-scoped filters coexist.
+- **For restore from a namespace-filtered backup**: No changes to restore workflow. Restore processes whatever is in the archive. Users can use existing `RestoreSpec.IncludedNamespaces` for additional filtering at restore time.
+- **`velero backup describe` output**: Extended to show per-namespace, per-kind filter details when the ResourcePolicy ConfigMap contains `namespacedFilterPolicies`.
+- **Validation errors**: Reported at backup start when the ResourcePolicy ConfigMap contains invalid `namespacedFilterPolicies` configurations. Consistent with how volume policy validation errors are reported today.
+
+## Alternatives Considered
+
+1. **CRD-Based `NamespacedFilters` Field**: Add `NamespacedFilters []NamespaceFilter` directly to `BackupSpec`. Rejected for this iteration due to heavy CRD change overhead. The ResourcePolicy approach achieves the same functionality with less API surface change.
+
+2. **Flat Fields on NamespacedFilterPolicy (No Per-Kind Selectors)**: Use flat fields (`includedResources`, `labelSelector`, `includedResourceNames`) shared across all kinds within a namespace. Rejected because it cannot express per-kind label selectors or per-kind name lists — a critical requirement for workloads where different resource types have different labels or naming conventions.
+
+3. **Scoped Label Selectors Only**: Augment existing label selectors with an optional namespace scope. Rejected because it only addresses label-scoped filtering and does not support per-namespace resource type filtering or name filtering.
+
+4. **Global Name Filter Only**: Add only global name filter fields. Rejected because it only addresses name filtering globally and does not address namespace-scoped or kind-scoped filtering.
+
+5. **Separate ConfigMap for Namespace Filters**: Use a new `BackupSpec` field pointing to a different ConfigMap (separate from volume policies). Rejected because it adds a new CRD field (which has similar reasons with #1) and splits configuration across multiple ConfigMaps.
diff --git a/internal/resourcepolicies/resource_policies.go b/internal/resourcepolicies/resource_policies.go
index ad61b7938..9d7ed25f6 100644
--- a/internal/resourcepolicies/resource_policies.go
+++ b/internal/resourcepolicies/resource_policies.go
@@ -24,11 +24,13 @@ import (
"k8s.io/apimachinery/pkg/util/sets"
"github.com/cockroachdb/errors"
+ "github.com/gobwas/glob"
"github.com/sirupsen/logrus"
corev1api "k8s.io/api/core/v1"
crclient "sigs.k8s.io/controller-runtime/pkg/client"
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
+ "github.com/vmware-tanzu/velero/pkg/util/wildcard"
)
type VolumeActionType string
@@ -54,6 +56,31 @@ type Action struct {
Parameters map[string]any `yaml:"parameters,omitempty"`
}
+// ResourceFilter defines a filter for specific resource kinds.
+type ResourceFilter struct {
+ Kinds []string `yaml:"kinds"`
+ LabelSelector map[string]string `yaml:"labelSelector,omitempty"`
+ OrLabelSelectors []map[string]string `yaml:"orLabelSelectors,omitempty"`
+ Names []string `yaml:"names,omitempty"`
+ ExcludedNames []string `yaml:"excludedNames,omitempty"`
+}
+
+// IsCatchAll returns true if the filter is a catch-all entry (empty kinds or ["*"])
+func (rf *ResourceFilter) IsCatchAll() bool {
+ return len(rf.Kinds) == 0 || (len(rf.Kinds) == 1 && rf.Kinds[0] == "*")
+}
+
+// ClusterScopedFilterPolicy defines backup filters scoped globally to cluster-scoped resources.
+type ClusterScopedFilterPolicy struct {
+ ResourceFilters []ResourceFilter `yaml:"resourceFilters"`
+}
+
+// NamespacedFilterPolicy defines backup filters scoped to specific namespaces.
+type NamespacedFilterPolicy struct {
+ Namespaces []string `yaml:"namespaces"`
+ ResourceFilters []ResourceFilter `yaml:"resourceFilters"`
+}
+
// IncludeExcludePolicy defined policy to include or exclude resources based on the names
type IncludeExcludePolicy struct {
// The following fields have the same semantics as those from the spec of backup.
@@ -95,17 +122,21 @@ type VolumePolicy struct {
// ResourcePolicies currently defined slice of volume policies to handle backup
type ResourcePolicies struct {
- Version string `yaml:"version"`
- VolumePolicies []VolumePolicy `yaml:"volumePolicies"`
- IncludeExcludePolicy *IncludeExcludePolicy `yaml:"includeExcludePolicy"`
+ Version string `yaml:"version"`
+ VolumePolicies []VolumePolicy `yaml:"volumePolicies"`
+ IncludeExcludePolicy *IncludeExcludePolicy `yaml:"includeExcludePolicy"`
+ ClusterScopedFilterPolicy *ClusterScopedFilterPolicy `yaml:"clusterScopedFilterPolicy,omitempty"`
+ NamespacedFilterPolicies []NamespacedFilterPolicy `yaml:"namespacedFilterPolicies,omitempty"`
// we may support other resource policies in the future, and they could be added separately
// OtherResourcePolicies []OtherResourcePolicy
}
type Policies struct {
- version string
- volumePolicies []volPolicy
- includeExcludePolicy *IncludeExcludePolicy
+ version string
+ volumePolicies []volPolicy
+ includeExcludePolicy *IncludeExcludePolicy
+ clusterScopedFilterPolicy *ClusterScopedFilterPolicy
+ namespacedFilterPolicies []NamespacedFilterPolicy
// OtherPolicies
}
@@ -158,6 +189,8 @@ func (p *Policies) BuildPolicy(resPolicies *ResourcePolicies) error {
p.version = resPolicies.Version
p.includeExcludePolicy = resPolicies.IncludeExcludePolicy
+ p.clusterScopedFilterPolicy = resPolicies.ClusterScopedFilterPolicy
+ p.namespacedFilterPolicies = resPolicies.NamespacedFilterPolicies
return nil
}
@@ -228,6 +261,14 @@ func (p *Policies) Validate() error {
}
}
+ if err := p.validateClusterScopedFilterPolicy(); err != nil {
+ return errors.WithStack(err)
+ }
+
+ if err := p.validateNamespacedFilterPolicies(); err != nil {
+ return errors.WithStack(err)
+ }
+
return nil
}
@@ -235,6 +276,14 @@ func (p *Policies) GetIncludeExcludePolicy() *IncludeExcludePolicy {
return p.includeExcludePolicy
}
+func (p *Policies) GetClusterScopedFilterPolicy() *ClusterScopedFilterPolicy {
+ return p.clusterScopedFilterPolicy
+}
+
+func (p *Policies) GetNamespacedFilterPolicies() []NamespacedFilterPolicy {
+ return p.namespacedFilterPolicies
+}
+
func GetResourcePoliciesFromBackup(
backup velerov1api.Backup,
client crclient.Client,
@@ -296,3 +345,117 @@ func getResourcePoliciesFromConfig(cm *corev1api.ConfigMap) (*Policies, error) {
return policies, nil
}
+
+func (p *Policies) validateNamespacedFilterPolicies() error {
+ seenPatterns := make(map[string][]int) // pattern -> list of policy indices
+
+ // Rule 1-7: Basic validation rules
+ for i, nfp := range p.namespacedFilterPolicies {
+ if len(nfp.Namespaces) == 0 {
+ return fmt.Errorf("namespacedFilterPolicies[%d]: at least one namespace must be specified", i)
+ }
+ if len(nfp.ResourceFilters) == 0 {
+ return fmt.Errorf("namespacedFilterPolicies[%d]: at least one resourceFilter must be specified", i)
+ }
+
+ // Rule 8 & 9: Validate glob patterns and collect namespace patterns for duplicate check
+ for j, pattern := range nfp.Namespaces {
+ if err := wildcard.ValidateNamespaceName(pattern); err != nil {
+ return fmt.Errorf("namespacedFilterPolicies[%d].namespaces[%d]: %w", i, j, err)
+ }
+ seenPatterns[pattern] = append(seenPatterns[pattern], i)
+ }
+
+ seenKinds := make(map[string]int)
+ hasCatchAll := false
+ for j, rf := range nfp.ResourceFilters {
+ if rf.IsCatchAll() {
+ if hasCatchAll {
+ return fmt.Errorf("namespacedFilterPolicies[%d]: only one catch-all resource filter is allowed", i)
+ }
+ hasCatchAll = true
+ if len(rf.Names) > 0 || len(rf.ExcludedNames) > 0 {
+ return fmt.Errorf("namespacedFilterPolicies[%d].resourceFilters[%d]: names or excludedNames cannot be specified for catch-all filters", i, j)
+ }
+ }
+
+ for _, kind := range rf.Kinds {
+ if kind == "*" {
+ continue // "*" is handled by IsCatchAll, no need to check duplicates against other kinds
+ }
+ if prevJ, ok := seenKinds[kind]; ok {
+ return fmt.Errorf("namespacedFilterPolicies[%d]: kind %q appears in both resourceFilters[%d] and resourceFilters[%d]", i, kind, prevJ, j)
+ }
+ seenKinds[kind] = j
+ }
+
+ if len(rf.LabelSelector) > 0 && len(rf.OrLabelSelectors) > 0 {
+ return fmt.Errorf("namespacedFilterPolicies[%d].resourceFilters[%d]: labelSelector and orLabelSelectors cannot co-exist", i, j)
+ }
+
+ // Validate glob patterns for names and excludedNames using gobwas/glob
+ for k, pattern := range rf.Names {
+ if _, err := glob.Compile(pattern); err != nil {
+ return fmt.Errorf("namespacedFilterPolicies[%d].resourceFilters[%d].names[%d]: invalid glob pattern %q: %v", i, j, k, pattern, err)
+ }
+ }
+ for k, pattern := range rf.ExcludedNames {
+ if _, err := glob.Compile(pattern); err != nil {
+ return fmt.Errorf("namespacedFilterPolicies[%d].resourceFilters[%d].excludedNames[%d]: invalid glob pattern %q: %v", i, j, k, pattern, err)
+ }
+ }
+ }
+ }
+
+ // Rule 8: Report exact duplicates only
+ for pattern, policyIndices := range seenPatterns {
+ if len(policyIndices) > 1 {
+ return fmt.Errorf(
+ "namespacedFilterPolicies: duplicate namespace pattern '%s' found in policies %v",
+ pattern, policyIndices)
+ }
+ }
+
+ return nil
+}
+
+func (p *Policies) validateClusterScopedFilterPolicy() error {
+ if p.clusterScopedFilterPolicy == nil {
+ return nil
+ }
+
+ if len(p.clusterScopedFilterPolicy.ResourceFilters) == 0 {
+ return fmt.Errorf("clusterScopedFilterPolicy: resourceFilters cannot be empty; remove the policy block entirely if it is not needed")
+ }
+
+ seenKinds := make(map[string]int)
+ for j, rf := range p.clusterScopedFilterPolicy.ResourceFilters {
+ if rf.IsCatchAll() {
+ return fmt.Errorf("clusterScopedFilterPolicy.resourceFilters[%d]: kinds must be specified (catch-all is not supported)", j)
+ }
+
+ for _, kind := range rf.Kinds {
+ if prevJ, ok := seenKinds[kind]; ok {
+ return fmt.Errorf("clusterScopedFilterPolicy: kind %q appears in both resourceFilters[%d] and resourceFilters[%d]", kind, prevJ, j)
+ }
+ seenKinds[kind] = j
+ }
+
+ if len(rf.LabelSelector) > 0 && len(rf.OrLabelSelectors) > 0 {
+ return fmt.Errorf("clusterScopedFilterPolicy.resourceFilters[%d]: labelSelector and orLabelSelectors cannot co-exist", j)
+ }
+
+ for k, pattern := range rf.Names {
+ if _, err := glob.Compile(pattern); err != nil {
+ return fmt.Errorf("clusterScopedFilterPolicy.resourceFilters[%d].names[%d]: invalid glob pattern %q: %v", j, k, pattern, err)
+ }
+ }
+ for k, pattern := range rf.ExcludedNames {
+ if _, err := glob.Compile(pattern); err != nil {
+ return fmt.Errorf("clusterScopedFilterPolicy.resourceFilters[%d].excludedNames[%d]: invalid glob pattern %q: %v", j, k, pattern, err)
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/internal/resourcepolicies/resource_policies_test.go b/internal/resourcepolicies/resource_policies_test.go
index 06b1bea1c..e5736a0e8 100644
--- a/internal/resourcepolicies/resource_policies_test.go
+++ b/internal/resourcepolicies/resource_policies_test.go
@@ -1242,3 +1242,441 @@ func TestPVCPhaseMatch(t *testing.T) {
})
}
}
+
+func TestNamespacedFilterPolicies(t *testing.T) {
+ testCases := []struct {
+ name string
+ yamlData string
+ wantErr bool
+ errMsg string
+ }{
+ {
+ name: "valid namespacedFilterPolicies with multiple kinds",
+ yamlData: `version: v1
+namespacedFilterPolicies:
+- namespaces: ["frontend", "backend"]
+ resourceFilters:
+ - kinds: ["Pod", "ConfigMap"]
+ labelSelector:
+ app: web
+ names: ["app-*"]
+ - kinds: ["Secret"]
+ excludedNames: ["temp-*"]`,
+ wantErr: false,
+ },
+ {
+ name: "valid namespacedFilterPolicies with glob patterns",
+ yamlData: `version: v1
+namespacedFilterPolicies:
+- namespaces: ["team-*"]
+ resourceFilters:
+ - kinds: ["Pod"]
+ orLabelSelectors:
+ - env: prod
+ - env: staging`,
+ wantErr: false,
+ },
+ {
+ name: "valid - overlapping patterns allowed (first-match semantics)",
+ yamlData: `version: v1
+namespacedFilterPolicies:
+- namespaces: ["team-frontend-*"]
+ resourceFilters:
+ - kinds: ["Pod", "ConfigMap", "Secret"]
+- namespaces: ["team-*"]
+ resourceFilters:
+ - kinds: ["Deployment", "Service"]`,
+ wantErr: false,
+ },
+ {
+ name: "invalid - no namespaces",
+ yamlData: `version: v1
+namespacedFilterPolicies:
+- namespaces: []
+ resourceFilters:
+ - kinds: ["Pod"]`,
+ wantErr: true,
+ errMsg: "at least one namespace must be specified",
+ },
+ {
+ name: "invalid - no resourceFilters",
+ yamlData: `version: v1
+namespacedFilterPolicies:
+- namespaces: ["test"]
+ resourceFilters: []`,
+ wantErr: true,
+ errMsg: "at least one resourceFilter must be specified",
+ },
+ {
+ name: "valid - asterisk catch-all",
+ yamlData: `version: v1
+namespacedFilterPolicies:
+- namespaces: ["test"]
+ resourceFilters:
+ - kinds: ["*"]
+ labelSelector:
+ app: web`,
+ wantErr: false,
+ },
+ {
+ name: "invalid - multiple asterisk kinds",
+ yamlData: `version: v1
+namespacedFilterPolicies:
+- namespaces: ["test"]
+ resourceFilters:
+ - kinds: ["*"]
+ labelSelector:
+ app: web
+ - kinds: ["*"]
+ labelSelector:
+ app: db`,
+ wantErr: true,
+ errMsg: "only one catch-all resource filter is allowed",
+ },
+ {
+ name: "invalid - empty and asterisk kinds",
+ yamlData: `version: v1
+namespacedFilterPolicies:
+- namespaces: ["test"]
+ resourceFilters:
+ - kinds: []
+ labelSelector:
+ app: web
+ - kinds: ["*"]
+ labelSelector:
+ app: db`,
+ wantErr: true,
+ errMsg: "only one catch-all resource filter is allowed",
+ },
+ {
+ name: "invalid - multiple empty kinds",
+ yamlData: `version: v1
+namespacedFilterPolicies:
+- namespaces: ["test"]
+ resourceFilters:
+ - kinds: []
+ labelSelector:
+ app: web
+ - kinds: []
+ labelSelector:
+ app: db`,
+ wantErr: true,
+ errMsg: "only one catch-all resource filter is allowed",
+ },
+ {
+ name: "invalid - names with empty kinds",
+ yamlData: `version: v1
+namespacedFilterPolicies:
+- namespaces: ["test"]
+ resourceFilters:
+ - kinds: []
+ names: ["app-*"]
+ labelSelector:
+ app: web`,
+ wantErr: true,
+ errMsg: "names or excludedNames cannot be specified for catch-all filters",
+ },
+ {
+ name: "invalid - excludedNames with empty kinds",
+ yamlData: `version: v1
+namespacedFilterPolicies:
+- namespaces: ["test"]
+ resourceFilters:
+ - kinds: []
+ excludedNames: ["app-*"]
+ labelSelector:
+ app: web`,
+ wantErr: true,
+ errMsg: "names or excludedNames cannot be specified for catch-all filters",
+ },
+ {
+ name: "valid - no label selectors with catch-all",
+ yamlData: `version: v1
+namespacedFilterPolicies:
+- namespaces: ["test"]
+ resourceFilters:
+ - kinds: ["*"]`,
+ wantErr: false,
+ },
+ {
+ name: "invalid - duplicate kinds",
+ yamlData: `version: v1
+namespacedFilterPolicies:
+- namespaces: ["test"]
+ resourceFilters:
+ - kinds: ["Pod"]
+ - kinds: ["Pod", "ConfigMap"]`,
+ wantErr: true,
+ errMsg: "kind \"Pod\" appears in both resourceFilters",
+ },
+ {
+ name: "invalid - both labelSelector and orLabelSelectors",
+ yamlData: `version: v1
+namespacedFilterPolicies:
+- namespaces: ["test"]
+ resourceFilters:
+ - kinds: ["Pod"]
+ labelSelector:
+ app: web
+ orLabelSelectors:
+ - env: prod`,
+ wantErr: true,
+ errMsg: "labelSelector and orLabelSelectors cannot co-exist",
+ },
+ {
+ name: "invalid - bad glob pattern in names",
+ yamlData: `version: v1
+namespacedFilterPolicies:
+- namespaces: ["test"]
+ resourceFilters:
+ - kinds: ["Pod"]
+ names: ["[invalid"]`,
+ wantErr: true,
+ errMsg: "invalid glob pattern",
+ },
+ {
+ name: "invalid - duplicate namespace pattern",
+ yamlData: `version: v1
+namespacedFilterPolicies:
+- namespaces: ["production"]
+ resourceFilters:
+ - kinds: ["Pod"]
+- namespaces: ["production"]
+ resourceFilters:
+ - kinds: ["ConfigMap"]`,
+ wantErr: true,
+ errMsg: "duplicate namespace pattern",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ resPolicies, err := unmarshalResourcePolicies(&tc.yamlData)
+ require.NoError(t, err) // Unmarshal should always succeed for our test cases
+
+ policies := &Policies{}
+ err = policies.BuildPolicy(resPolicies)
+ require.NoError(t, err) // BuildPolicy should always succeed for our test cases
+
+ err = policies.Validate()
+ if tc.wantErr {
+ require.Error(t, err)
+ if tc.errMsg != "" {
+ assert.Contains(t, err.Error(), tc.errMsg)
+ }
+ } else {
+ require.NoError(t, err)
+
+ // Verify that we can retrieve the policies
+ nfPolicies := policies.GetNamespacedFilterPolicies()
+ assert.GreaterOrEqual(t, len(nfPolicies), 1) // Valid test cases have at least 1 policy
+ }
+ })
+ }
+}
+
+func TestNamespacedFilterPoliciesAccessor(t *testing.T) {
+ yamlData := `version: v1
+namespacedFilterPolicies:
+- namespaces: ["frontend"]
+ resourceFilters:
+ - kinds: ["Pod"]
+ labelSelector:
+ app: web`
+
+ resPolicies, err := unmarshalResourcePolicies(&yamlData)
+ require.NoError(t, err)
+
+ policies := &Policies{}
+ err = policies.BuildPolicy(resPolicies)
+ require.NoError(t, err)
+
+ nfPolicies := policies.GetNamespacedFilterPolicies()
+ require.Len(t, nfPolicies, 1)
+
+ policy := nfPolicies[0]
+ assert.Equal(t, []string{"frontend"}, policy.Namespaces)
+ assert.Len(t, policy.ResourceFilters, 1)
+
+ rf := policy.ResourceFilters[0]
+ assert.Equal(t, []string{"Pod"}, rf.Kinds)
+ assert.Equal(t, map[string]string{"app": "web"}, rf.LabelSelector)
+}
+
+func TestFirstMatchSemantics(t *testing.T) {
+ yamlData := `version: v1
+namespacedFilterPolicies:
+- namespaces: ["team-frontend-*", "specific-ns"]
+ resourceFilters:
+ - kinds: ["Pod", "ConfigMap", "Secret"]
+- namespaces: ["team-*", "another-pattern"]
+ resourceFilters:
+ - kinds: ["Deployment", "Service"]`
+
+ resPolicies, err := unmarshalResourcePolicies(&yamlData)
+ require.NoError(t, err)
+
+ policies := &Policies{}
+ err = policies.BuildPolicy(resPolicies)
+ require.NoError(t, err)
+
+ err = policies.Validate()
+ require.NoError(t, err)
+
+ nfPolicies := policies.GetNamespacedFilterPolicies()
+ require.Len(t, nfPolicies, 2)
+
+ // Verify the first policy has the more specific patterns
+ policy1 := nfPolicies[0]
+ assert.Equal(t, []string{"team-frontend-*", "specific-ns"}, policy1.Namespaces)
+ assert.Equal(t, []string{"Pod", "ConfigMap", "Secret"}, policy1.ResourceFilters[0].Kinds)
+
+ // Verify the second policy has the broader patterns
+ policy2 := nfPolicies[1]
+ assert.Equal(t, []string{"team-*", "another-pattern"}, policy2.Namespaces)
+ assert.Equal(t, []string{"Deployment", "Service"}, policy2.ResourceFilters[0].Kinds)
+}
+
+func TestClusterScopedFilterPolicies(t *testing.T) {
+ testCases := []struct {
+ name string
+ yamlData string
+ wantErr bool
+ errMsg string
+ }{
+ {
+ name: "valid - single kind with names",
+ yamlData: `version: v1
+clusterScopedFilterPolicy:
+ resourceFilters:
+ - kinds: ["ClusterRole"]
+ names: ["my-app-*"]`,
+ wantErr: false,
+ },
+ {
+ name: "valid - multi-kind with labelSelector",
+ yamlData: `version: v1
+clusterScopedFilterPolicy:
+ resourceFilters:
+ - kinds: ["ClusterRole", "ClusterRoleBinding"]
+ labelSelector:
+ app: my-app`,
+ wantErr: false,
+ },
+ {
+ name: "valid - orLabelSelectors",
+ yamlData: `version: v1
+clusterScopedFilterPolicy:
+ resourceFilters:
+ - kinds: ["CustomResourceDefinition"]
+ orLabelSelectors:
+ - app: my-app
+ - app: other-app`,
+ wantErr: false,
+ },
+ {
+ name: "valid - excludedNames",
+ yamlData: `version: v1
+clusterScopedFilterPolicy:
+ resourceFilters:
+ - kinds: ["ClusterRole"]
+ names: ["my-*"]
+ excludedNames: ["my-debug-*"]`,
+ wantErr: false,
+ },
+ {
+ name: "invalid - empty resourceFilters",
+ yamlData: `version: v1
+clusterScopedFilterPolicy:
+ resourceFilters: []`,
+ wantErr: true,
+ errMsg: "resourceFilters cannot be empty; remove the policy block entirely if it is not needed",
+ },
+ {
+ name: "invalid - empty kinds in clusterScopedFilterPolicy",
+ yamlData: `version: v1
+clusterScopedFilterPolicy:
+ resourceFilters:
+ - kinds: []
+ names: ["my-app-*"]`,
+ wantErr: true,
+ errMsg: "kinds must be specified",
+ },
+ {
+ name: "invalid - asterisk kinds (explicit catch-all) in clusterScopedFilterPolicy",
+ yamlData: `version: v1
+clusterScopedFilterPolicy:
+ resourceFilters:
+ - kinds: ["*"]
+ labelSelector:
+ app: my-app`,
+ wantErr: true,
+ errMsg: "kinds must be specified",
+ },
+ {
+ name: "invalid - duplicate kinds across entries",
+ yamlData: `version: v1
+clusterScopedFilterPolicy:
+ resourceFilters:
+ - kinds: ["ClusterRole"]
+ names: ["my-app-*"]
+ - kinds: ["ClusterRole"]
+ labelSelector:
+ app: other`,
+ wantErr: true,
+ errMsg: `kind "ClusterRole" appears in both`,
+ },
+ {
+ name: "invalid - labelSelector and orLabelSelectors co-exist",
+ yamlData: `version: v1
+clusterScopedFilterPolicy:
+ resourceFilters:
+ - kinds: ["ClusterRole"]
+ labelSelector:
+ app: my-app
+ orLabelSelectors:
+ - app: other`,
+ wantErr: true,
+ errMsg: "labelSelector and orLabelSelectors cannot co-exist",
+ },
+ {
+ name: "invalid - bad glob in names",
+ yamlData: `version: v1
+clusterScopedFilterPolicy:
+ resourceFilters:
+ - kinds: ["ClusterRole"]
+ names: ["[invalid"]`,
+ wantErr: true,
+ errMsg: "invalid glob pattern",
+ },
+ {
+ name: "invalid - bad glob in excludedNames",
+ yamlData: `version: v1
+clusterScopedFilterPolicy:
+ resourceFilters:
+ - kinds: ["ClusterRole"]
+ excludedNames: ["[bad"]`,
+ wantErr: true,
+ errMsg: "invalid glob pattern",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ resPolicies, err := unmarshalResourcePolicies(&tc.yamlData)
+ require.NoError(t, err)
+
+ policies := &Policies{}
+ err = policies.BuildPolicy(resPolicies)
+ require.NoError(t, err)
+
+ err = policies.Validate()
+ if tc.wantErr {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.errMsg)
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+}
diff --git a/pkg/backup/backup.go b/pkg/backup/backup.go
index aeb318337..268d7be09 100644
--- a/pkg/backup/backup.go
+++ b/pkg/backup/backup.go
@@ -26,16 +26,19 @@ import (
"io"
"os"
"path/filepath"
+ "strings"
"sync"
"time"
"github.com/cockroachdb/errors"
+ "github.com/gobwas/glob"
"github.com/sirupsen/logrus"
corev1api "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
kubeerrs "k8s.io/apimachinery/pkg/util/errors"
@@ -43,6 +46,7 @@ import (
kbclient "sigs.k8s.io/controller-runtime/pkg/client"
"github.com/vmware-tanzu/velero/internal/hook"
+ "github.com/vmware-tanzu/velero/internal/resourcepolicies"
"github.com/vmware-tanzu/velero/internal/volume"
"github.com/vmware-tanzu/velero/internal/volumehelper"
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
@@ -333,6 +337,52 @@ func (kb *kubernetesBackupper) BackupWithResolvers(
backupRequest.ResourceIncludesExcludes = srie
}
+ if backupRequest.ResPolicies != nil {
+ clusterScopedFilterPolicy := backupRequest.ResPolicies.GetClusterScopedFilterPolicy()
+ if clusterScopedFilterPolicy != nil {
+ backupRequest.ClusterScopedFilterMap, err = resolveClusterScopedFilterPolicy(
+ clusterScopedFilterPolicy,
+ kb.discoveryHelper,
+ log,
+ )
+ if err != nil {
+ return err
+ }
+ log.Infof("Resolved clusterScopedFilterPolicy: %d kind group(s) in cluster-scoped filter map",
+ len(backupRequest.ClusterScopedFilterMap))
+ }
+
+ nfPolicies := backupRequest.ResPolicies.GetNamespacedFilterPolicies()
+ if len(nfPolicies) > 0 {
+ backupRequest.NamespacedFilterMap, backupRequest.NamespacedFilterPatterns, err = resolveNamespacedFilterPolicies(
+ nfPolicies,
+ kb.discoveryHelper,
+ log,
+ )
+ if err != nil {
+ return err
+ }
+ log.Infof("Resolved namespacedFilterPolicies: %d namespace pattern(s) registered",
+ len(backupRequest.NamespacedFilterPatterns))
+ for _, p := range backupRequest.NamespacedFilterPatterns {
+ nsf := backupRequest.NamespacedFilterMap[p.Pattern]
+ log.WithFields(logrus.Fields{
+ "namespacePattern": p.Pattern,
+ "kindCount": len(nsf.ResourceFilterMap),
+ "hasCatchAll": nsf.CatchAllFilter != nil,
+ }).Debug("namespacedFilterPolicies: namespace pattern registered")
+ for kind := range nsf.ResourceFilterMap {
+ if backupRequest.ResourceIncludesExcludes.ShouldExclude(kind) {
+ log.WithFields(logrus.Fields{
+ "namespacePattern": p.Pattern,
+ "kind": kind,
+ }).Warn("namespacedFilterPolicies entry lists a kind that is globally excluded by includeExcludePolicy; the per-namespace filter entry has no effect")
+ }
+ }
+ }
+ }
+ }
+
log.Infof("Backing up all volumes using pod volume backup: %t", boolptr.IsSetToTrue(backupRequest.Backup.Spec.DefaultVolumesToFsBackup))
backupRequest.ResourceHooks, err = getResourceHooks(backupRequest.Spec.Hooks.Resources, kb.discoveryHelper)
@@ -1322,3 +1372,140 @@ func putVolumeInfos(
return backupStore.PutBackupVolumeInfos(backupName, backupVolumeInfoBuf)
}
+
+func resolveClusterScopedFilterPolicy(
+ policy *resourcepolicies.ClusterScopedFilterPolicy,
+ helper discovery.Helper,
+ log logrus.FieldLogger,
+) (map[string]*ResolvedResourceFilter, error) {
+ rfMap := make(map[string]*ResolvedResourceFilter)
+
+ for _, rf := range policy.ResourceFilters {
+ resolved, err := resolveResourceFilter(rf)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, kind := range rf.Kinds {
+ gr, apiResource, err := helper.ResourceFor(
+ schema.GroupVersionResource{Resource: kind},
+ )
+ if err != nil {
+ log.WithField("kind", kind).Warnf(
+ "Cannot resolve kind via discovery, using as-is: %v", err)
+ rfMap[kind] = resolved
+ continue
+ }
+ if apiResource.Namespaced {
+ log.WithField("kind", kind).Warnf(
+ "kind %q in clusterScopedFilterPolicy is namespace-scoped; "+
+ "it will never match in a cluster-scoped filter — did you mean namespacedFilterPolicies?", kind)
+ }
+ rfMap[gr.GroupResource().String()] = resolved
+ }
+ }
+
+ return rfMap, nil
+}
+
+func resolveResourceFilter(rf resourcepolicies.ResourceFilter) (*ResolvedResourceFilter, error) {
+ var selector labels.Selector
+ if len(rf.LabelSelector) > 0 {
+ var err error
+ selector, err = labels.ValidatedSelectorFromSet(labels.Set(rf.LabelSelector))
+ if err != nil {
+ return nil, fmt.Errorf("invalid label selector in resource filter: %w", err)
+ }
+ }
+
+ var orSelectors []labels.Selector
+ for _, ols := range rf.OrLabelSelectors {
+ s, err := labels.ValidatedSelectorFromSet(labels.Set(ols))
+ if err != nil {
+ return nil, fmt.Errorf("invalid OR label selector in resource filter: %w", err)
+ }
+ orSelectors = append(orSelectors, s)
+ }
+
+ var nameIE *collections.IncludesExcludes
+ if len(rf.Names) > 0 || len(rf.ExcludedNames) > 0 {
+ nameIE = collections.NewIncludesExcludes()
+ nameIE.Includes(rf.Names...)
+ nameIE.Excludes(rf.ExcludedNames...)
+ }
+
+ return &ResolvedResourceFilter{
+ LabelSelector: selector,
+ OrLabelSelectors: orSelectors,
+ NameIE: nameIE,
+ }, nil
+}
+
+func resolveNamespacedFilterPolicies(
+ policies []resourcepolicies.NamespacedFilterPolicy,
+ helper discovery.Helper,
+ log logrus.FieldLogger,
+) (map[string]*ResolvedNamespaceFilter, []NamespacedFilterPattern, error) {
+ result := make(map[string]*ResolvedNamespaceFilter)
+ var patternOrder []NamespacedFilterPattern
+
+ for _, policy := range policies {
+ rfMap := make(map[string]*ResolvedResourceFilter)
+ var nsFilter *ResolvedNamespaceFilter
+
+ for _, rf := range policy.ResourceFilters {
+ resolved, err := resolveResourceFilter(rf)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ if rf.IsCatchAll() {
+ if nsFilter == nil {
+ nsFilter = &ResolvedNamespaceFilter{ResourceFilterMap: rfMap}
+ }
+ nsFilter.CatchAllFilter = resolved
+ } else {
+ // Resolve each kind to a fully-qualified group-resource string with improved error handling
+ for _, kind := range rf.Kinds {
+ gr, apiResource, err := helper.ResourceFor(
+ schema.GroupVersionResource{Resource: kind},
+ )
+ if err != nil {
+ // Log warning but continue - allows for forward compatibility
+ log.WithField("kind", kind).Warnf(
+ "Cannot resolve kind via discovery, using as-is: %v", err)
+ rfMap[kind] = resolved
+ continue
+ }
+ if !apiResource.Namespaced {
+ log.WithField("kind", kind).Warnf(
+ "kind %q in namespacedFilterPolicies is cluster-scoped; "+
+ "it will never match in a namespace-scoped filter — did you mean clusterScopedFilterPolicy?", kind)
+ }
+ rfMap[gr.GroupResource().String()] = resolved
+ }
+ }
+ }
+
+ if nsFilter == nil {
+ nsFilter = &ResolvedNamespaceFilter{}
+ }
+ nsFilter.ResourceFilterMap = rfMap
+ for _, nsPattern := range policy.Namespaces {
+ result[nsPattern] = nsFilter
+ // Pre-compile glob patterns once here; exact names are matched via map
+ // and never reach the pattern loop, so only wildcard patterns need Compiled set.
+ entry := NamespacedFilterPattern{Pattern: nsPattern}
+ if strings.ContainsAny(nsPattern, "*?[") {
+ if compiled, cerr := glob.Compile(nsPattern); cerr == nil {
+ entry.Compiled = compiled
+ } else {
+ // Pattern already validated; this branch should not be reached
+ log.WithField("pattern", nsPattern).Warnf("Failed to pre-compile glob pattern: %v", cerr)
+ }
+ }
+ patternOrder = append(patternOrder, entry)
+ }
+ }
+ return result, patternOrder, nil
+}
diff --git a/pkg/backup/backup_test.go b/pkg/backup/backup_test.go
index 07baa639e..14ab98aa0 100644
--- a/pkg/backup/backup_test.go
+++ b/pkg/backup/backup_test.go
@@ -31,6 +31,7 @@ import (
"time"
"github.com/cockroachdb/errors"
+ "github.com/gobwas/glob"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
@@ -40,7 +41,9 @@ import (
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
"github.com/vmware-tanzu/velero/internal/resourcepolicies"
"github.com/vmware-tanzu/velero/internal/volume"
@@ -5736,3 +5739,419 @@ func (f *fakeSingleObjectBackupStoreGetter) Get(*velerov1.BackupStorageLocation,
func NewFakeSingleObjectBackupStoreGetter(store persistence.BackupStore) persistence.ObjectBackupStoreGetter {
return &fakeSingleObjectBackupStoreGetter{store: store}
}
+func TestResolveResourceFilter(t *testing.T) {
+ tests := []struct {
+ name string
+ rf resourcepolicies.ResourceFilter
+ expectErr bool
+ checkResult func(*testing.T, *ResolvedResourceFilter)
+ }{
+ {
+ name: "valid label selector",
+ rf: resourcepolicies.ResourceFilter{
+ LabelSelector: map[string]string{"app": "foo"},
+ },
+ expectErr: false,
+ checkResult: func(t *testing.T, r *ResolvedResourceFilter) {
+ t.Helper()
+ require.NotNil(t, r)
+ require.NotNil(t, r.LabelSelector)
+ assert.True(t, r.LabelSelector.Matches(labels.Set{"app": "foo"}))
+ },
+ },
+ {
+ name: "invalid label selector",
+ rf: resourcepolicies.ResourceFilter{
+ LabelSelector: map[string]string{"invalid/label/key": "value"},
+ },
+ expectErr: true,
+ },
+ {
+ name: "valid or label selectors",
+ rf: resourcepolicies.ResourceFilter{
+ OrLabelSelectors: []map[string]string{
+ {"app": "foo"},
+ {"app": "bar"},
+ },
+ },
+ expectErr: false,
+ checkResult: func(t *testing.T, r *ResolvedResourceFilter) {
+ t.Helper()
+ require.NotNil(t, r)
+ require.Len(t, r.OrLabelSelectors, 2)
+ },
+ },
+ {
+ name: "invalid or label selectors",
+ rf: resourcepolicies.ResourceFilter{
+ OrLabelSelectors: []map[string]string{
+ {"invalid/label/key": "value"},
+ },
+ },
+ expectErr: true,
+ },
+ {
+ name: "names and excluded names",
+ rf: resourcepolicies.ResourceFilter{
+ Names: []string{"inc1", "inc2"},
+ ExcludedNames: []string{"exc1"},
+ },
+ expectErr: false,
+ checkResult: func(t *testing.T, r *ResolvedResourceFilter) {
+ t.Helper()
+ require.NotNil(t, r)
+ require.NotNil(t, r.NameIE)
+ assert.True(t, r.NameIE.ShouldInclude("inc1"))
+ assert.False(t, r.NameIE.ShouldInclude("exc1"))
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ res, err := resolveResourceFilter(tc.rf)
+ if tc.expectErr {
+ require.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ if tc.checkResult != nil {
+ tc.checkResult(t, res)
+ }
+ }
+ })
+ }
+}
+
+type mockDiscoveryHelper struct {
+ discovery.Helper
+ ResourceForFunc func(input schema.GroupVersionResource) (schema.GroupVersionResource, metav1.APIResource, error)
+}
+
+func (m *mockDiscoveryHelper) ResourceFor(input schema.GroupVersionResource) (schema.GroupVersionResource, metav1.APIResource, error) {
+ if m.ResourceForFunc != nil {
+ return m.ResourceForFunc(input)
+ }
+ return m.Helper.ResourceFor(input)
+}
+
+func TestResolveClusterScopedFilterPolicy(t *testing.T) {
+ helper := test.NewFakeDiscoveryHelper(true, nil)
+ log := test.NewLogger()
+
+ policy := &resourcepolicies.ClusterScopedFilterPolicy{
+ ResourceFilters: []resourcepolicies.ResourceFilter{
+ {
+ Kinds: []string{"pods", "secrets"},
+ LabelSelector: map[string]string{"app": "foo"},
+ },
+ {
+ Kinds: []string{"invalid-kind"},
+ LabelSelector: map[string]string{"invalid/label/key": "value"},
+ },
+ },
+ }
+
+ // Test with invalid label selector to trigger error
+ _, err := resolveClusterScopedFilterPolicy(policy, helper, log)
+ require.Error(t, err)
+
+ // Test valid policy
+ validPolicy := &resourcepolicies.ClusterScopedFilterPolicy{
+ ResourceFilters: []resourcepolicies.ResourceFilter{
+ {
+ Kinds: []string{"pods", "secrets"},
+ LabelSelector: map[string]string{"app": "foo"},
+ },
+ },
+ }
+ res, err := resolveClusterScopedFilterPolicy(validPolicy, helper, log)
+ require.NoError(t, err)
+ require.Len(t, res, 2)
+ assert.Contains(t, res, "pods")
+ assert.Contains(t, res, "secrets")
+ assert.True(t, res["pods"].LabelSelector.Matches(labels.Set{"app": "foo"}))
+
+ // Test warning branches
+ mockHelper := &mockDiscoveryHelper{
+ Helper: helper,
+ ResourceForFunc: func(input schema.GroupVersionResource) (schema.GroupVersionResource, metav1.APIResource, error) {
+ if input.Resource == "invalid-resource" {
+ return schema.GroupVersionResource{}, metav1.APIResource{}, errors.New("cannot resolve")
+ }
+ if input.Resource == "namespaced-resource" {
+ return schema.GroupVersionResource{Resource: "namespaced-resource"}, metav1.APIResource{Namespaced: true, Name: "namespaced-resource"}, nil
+ }
+ return helper.ResourceFor(input)
+ },
+ }
+
+ policyWithWarns := &resourcepolicies.ClusterScopedFilterPolicy{
+ ResourceFilters: []resourcepolicies.ResourceFilter{
+ {
+ Kinds: []string{"invalid-resource", "namespaced-resource"},
+ },
+ },
+ }
+ res2, err2 := resolveClusterScopedFilterPolicy(policyWithWarns, mockHelper, log)
+ require.NoError(t, err2)
+ assert.Contains(t, res2, "invalid-resource")
+ assert.Contains(t, res2, "namespaced-resource")
+}
+
+func TestResolveNamespacedFilterPolicies(t *testing.T) {
+ helper := test.NewFakeDiscoveryHelper(true, nil)
+ log := test.NewLogger()
+
+ policies := []resourcepolicies.NamespacedFilterPolicy{
+ {
+ Namespaces: []string{"ns1", "ns-*"},
+ ResourceFilters: []resourcepolicies.ResourceFilter{
+ {
+ Kinds: []string{"pods"},
+ LabelSelector: map[string]string{"app": "foo"},
+ },
+ {
+ Kinds: []string{"*"},
+ LabelSelector: map[string]string{"catch": "all"},
+ },
+ },
+ },
+ }
+
+ res, patterns, err := resolveNamespacedFilterPolicies(policies, helper, log)
+ require.NoError(t, err)
+ require.Len(t, res, 2)
+ require.Len(t, patterns, 2)
+
+ assert.Contains(t, res, "ns1")
+ assert.Contains(t, res, "ns-*")
+
+ ns1Filter := res["ns1"]
+ require.NotNil(t, ns1Filter)
+ require.NotNil(t, ns1Filter.CatchAllFilter)
+ assert.True(t, ns1Filter.CatchAllFilter.LabelSelector.Matches(labels.Set{"catch": "all"}))
+ require.Contains(t, ns1Filter.ResourceFilterMap, "pods")
+ assert.True(t, ns1Filter.ResourceFilterMap["pods"].LabelSelector.Matches(labels.Set{"app": "foo"}))
+
+ // Test with invalid label selector
+ invalidPolicies := []resourcepolicies.NamespacedFilterPolicy{
+ {
+ Namespaces: []string{"ns1"},
+ ResourceFilters: []resourcepolicies.ResourceFilter{
+ {
+ Kinds: []string{"pods"},
+ LabelSelector: map[string]string{"invalid/label/key": "value"},
+ },
+ },
+ },
+ }
+ _, _, err = resolveNamespacedFilterPolicies(invalidPolicies, helper, log)
+ require.Error(t, err)
+
+ // Test warning branches
+ mockHelper := &mockDiscoveryHelper{
+ Helper: helper,
+ ResourceForFunc: func(input schema.GroupVersionResource) (schema.GroupVersionResource, metav1.APIResource, error) {
+ if input.Resource == "invalid-resource" {
+ return schema.GroupVersionResource{}, metav1.APIResource{}, errors.New("cannot resolve")
+ }
+ if input.Resource == "cluster-scoped-resource" {
+ return schema.GroupVersionResource{Resource: "cluster-scoped-resource"}, metav1.APIResource{Namespaced: false, Name: "cluster-scoped-resource"}, nil
+ }
+ return schema.GroupVersionResource{Resource: input.Resource}, metav1.APIResource{Namespaced: true, Name: input.Resource}, nil
+ },
+ }
+
+ policyWithWarns := []resourcepolicies.NamespacedFilterPolicy{
+ {
+ Namespaces: []string{"ns1"},
+ ResourceFilters: []resourcepolicies.ResourceFilter{
+ {
+ Kinds: []string{"invalid-resource", "cluster-scoped-resource"},
+ },
+ },
+ },
+ }
+ resWarns, _, errWarns := resolveNamespacedFilterPolicies(policyWithWarns, mockHelper, log)
+ require.NoError(t, errWarns)
+ require.Contains(t, resWarns["ns1"].ResourceFilterMap, "invalid-resource")
+ require.Contains(t, resWarns["ns1"].ResourceFilterMap, "cluster-scoped-resource")
+}
+
+func TestBackupWithResPoliciesLogs(t *testing.T) {
+ itemBlockPool := StartItemBlockWorkerPool(t.Context(), 1, logrus.StandardLogger())
+ defer itemBlockPool.Stop()
+
+ h := newHarness(t, itemBlockPool)
+
+ // Add some resources so discovery helper knows about them
+ h.addItems(t, test.Pods(builder.ForPod("ns1", "pod-1").Result()))
+ h.addItems(t, test.PVs(builder.ForPersistentVolume("pv-1").Result()))
+
+ backupReq := &Request{
+ Backup: defaultBackup().ExcludedNamespaceScopedResources("pods").Result(),
+ SkippedPVTracker: NewSkipPVTracker(),
+ BackedUpItems: NewBackedUpItemsMap(),
+ WorkerPool: itemBlockPool,
+ }
+
+ p := new(resourcepolicies.Policies)
+ inputPolicy := &resourcepolicies.ResourcePolicies{
+ Version: "v1",
+ ClusterScopedFilterPolicy: &resourcepolicies.ClusterScopedFilterPolicy{
+ ResourceFilters: []resourcepolicies.ResourceFilter{
+ {Kinds: []string{"pods", "invalid-cluster-kind"}},
+ },
+ },
+ NamespacedFilterPolicies: []resourcepolicies.NamespacedFilterPolicy{
+ {
+ Namespaces: []string{"ns1"},
+ ResourceFilters: []resourcepolicies.ResourceFilter{
+ {Kinds: []string{"persistentvolumes", "pods", "invalid-ns-kind"}},
+ },
+ },
+ },
+ }
+ require.NoError(t, p.BuildPolicy(inputPolicy))
+ backupReq.ResPolicies = p
+
+ backupFile := bytes.NewBuffer([]byte{})
+ err := h.backupper.Backup(h.log, backupReq, backupFile, nil, nil, nil)
+ require.NoError(t, err)
+
+ // Add test to cover error returns from resolve policies
+ badClusterPol := &resourcepolicies.ClusterScopedFilterPolicy{
+ ResourceFilters: []resourcepolicies.ResourceFilter{
+ {
+ Kinds: []string{"pods"},
+ LabelSelector: map[string]string{"invalid/label/key": "value"},
+ },
+ },
+ }
+ pBadCluster := new(resourcepolicies.Policies)
+ require.NoError(t, pBadCluster.BuildPolicy(&resourcepolicies.ResourcePolicies{
+ Version: "v1",
+ ClusterScopedFilterPolicy: badClusterPol,
+ }))
+ backupReq.ResPolicies = pBadCluster
+ err = h.backupper.Backup(h.log, backupReq, backupFile, nil, nil, nil)
+ require.Error(t, err)
+
+ badNsPol := []resourcepolicies.NamespacedFilterPolicy{
+ {
+ Namespaces: []string{"ns1"},
+ ResourceFilters: []resourcepolicies.ResourceFilter{
+ {
+ Kinds: []string{"pods"},
+ LabelSelector: map[string]string{"invalid/label/key": "value"},
+ },
+ },
+ },
+ }
+ pBadNs := new(resourcepolicies.Policies)
+ require.NoError(t, pBadNs.BuildPolicy(&resourcepolicies.ResourcePolicies{
+ Version: "v1",
+ NamespacedFilterPolicies: badNsPol,
+ }))
+ backupReq.ResPolicies = pBadNs
+ err = h.backupper.Backup(h.log, backupReq, backupFile, nil, nil, nil)
+ require.Error(t, err)
+}
+
+func TestGetNamespaceFilter(t *testing.T) {
+ // Pre-compile our globs to simulate what resolveNamespacedFilterPolicies does
+ teamFrontendGlob, err := glob.Compile("team-frontend-*")
+ require.NoError(t, err)
+
+ teamGlob, err := glob.Compile("team-*")
+ require.NoError(t, err)
+
+ // Define our filter map
+ filterMap := map[string]*ResolvedNamespaceFilter{
+ "exact-match-ns": {CatchAllFilter: &ResolvedResourceFilter{}},
+ "team-frontend-*": {CatchAllFilter: &ResolvedResourceFilter{}},
+ "team-*": {CatchAllFilter: &ResolvedResourceFilter{}},
+ }
+
+ // Create request with patterns in a specific order (first-match semantics)
+ req := &Request{
+ NamespacedFilterMap: filterMap,
+ NamespacedFilterPatterns: []NamespacedFilterPattern{
+ {Pattern: "team-frontend-*", Compiled: teamFrontendGlob}, // Most specific first
+ {Pattern: "team-*", Compiled: teamGlob}, // Broader second
+ },
+ }
+
+ tests := []struct {
+ name string
+ namespace string
+ expectNil bool
+ expectMatched string // The pattern or exact string that should match
+ }{
+ {
+ name: "exact string match bypasses glob matching",
+ namespace: "exact-match-ns",
+ expectNil: false,
+ expectMatched: "exact-match-ns",
+ },
+ {
+ name: "reviewer requested: glob pattern matching",
+ namespace: "team-backend-prod",
+ expectNil: false,
+ expectMatched: "team-*",
+ },
+ {
+ name: "reviewer requested: first-match ordering",
+ namespace: "team-frontend-prod",
+ expectNil: false,
+ expectMatched: "team-frontend-*", // Should match this because it's first in NamespacedFilterPatterns
+ },
+ {
+ name: "no match returns nil",
+ namespace: "unrelated-ns",
+ expectNil: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // First call (populates cache)
+ result := req.GetNamespaceFilter(tt.namespace)
+
+ if tt.expectNil {
+ assert.Nil(t, result)
+
+ // Verify negative cache
+ val, ok := req.NamespaceFilterCache.Load(tt.namespace)
+ assert.True(t, ok)
+ assert.Nil(t, val)
+ } else {
+ assert.NotNil(t, result)
+ // Ensure the returned filter points to the correct reference in our map
+ assert.Same(t, filterMap[tt.expectMatched], result)
+
+ // Verify positive cache
+ val, ok := req.NamespaceFilterCache.Load(tt.namespace)
+ assert.True(t, ok)
+ assert.Same(t, filterMap[tt.expectMatched], val)
+ }
+
+ // Second call (hits cache)
+ result2 := req.GetNamespaceFilter(tt.namespace)
+ assert.Same(t, result, result2)
+ })
+ }
+}
+
+func TestGetNamespaceFilter_CacheBypass(t *testing.T) {
+ req := &Request{
+ NamespacedFilterMap: make(map[string]*ResolvedNamespaceFilter),
+ }
+
+ cachedFilter := &ResolvedNamespaceFilter{}
+ req.NamespaceFilterCache.Store("cached-ns", cachedFilter)
+
+ // Since NamespacedFilterMap is empty, this would normally return nil,
+ // but the cache should return our cachedFilter.
+ assert.Same(t, cachedFilter, req.GetNamespaceFilter("cached-ns"))
+}
diff --git a/pkg/backup/item_backupper.go b/pkg/backup/item_backupper.go
index 5481fe6fd..f43888252 100644
--- a/pkg/backup/item_backupper.go
+++ b/pkg/backup/item_backupper.go
@@ -142,6 +142,42 @@ func (ib *itemBackupper) itemInclusionChecks(log logrus.FieldLogger, mustInclude
log.Info("Excluding item because resource is excluded")
return false
}
+
+ // Per-kind name filter from ResourcePolicy namespace filter.
+ if namespace != "" {
+ if nsFilter := ib.backupRequest.GetNamespaceFilter(namespace); nsFilter != nil {
+ rf := nsFilter.ResourceFilterMap[groupResource.String()]
+ if rf == nil {
+ rf = nsFilter.CatchAllFilter
+ }
+ // When rf is still nil the item's kind is not listed in the namespace filter and
+ // there is no catch-all entry. This is an intentional permissive passthrough:
+ // plugin-injected additional items (returned by BackupItemAction) must be able
+ // to reach the archive even when their kind was not explicitly listed in
+ // namespacedFilterPolicies, because excluding them at Stage 2 would break backup
+ // completeness. For example, a CSI plugin may inject a VolumeSnapshotContent
+ // as an additional item that is required for a correct restore. Kind-level
+ // exclusion for the primary collection pass is enforced earlier in
+ // item_collector.go (Stage 1).
+ if rf != nil && rf.NameIE != nil {
+ if !rf.NameIE.ShouldInclude(metadata.GetName()) {
+ log.Infof("Excluding item: name does not match resource filter for kind %s",
+ groupResource)
+ return false
+ }
+ }
+ }
+ } else {
+ // Cluster-scoped resource name filter
+ if ib.backupRequest.ClusterScopedFilterMap != nil {
+ if rf, ok := ib.backupRequest.ClusterScopedFilterMap[groupResource.String()]; ok && rf.NameIE != nil {
+ if !rf.NameIE.ShouldInclude(metadata.GetName()) {
+ log.Infof("Excluding item: name does not match clusterScopedFilterPolicy for kind %s", groupResource)
+ return false
+ }
+ }
+ }
+ }
}
if metadata.GetDeletionTimestamp() != nil {
diff --git a/pkg/backup/item_backupper_test.go b/pkg/backup/item_backupper_test.go
index be91b6d34..f3769a998 100644
--- a/pkg/backup/item_backupper_test.go
+++ b/pkg/backup/item_backupper_test.go
@@ -21,20 +21,20 @@ import (
"testing"
"github.com/sirupsen/logrus"
- "github.com/stretchr/testify/require"
- "k8s.io/apimachinery/pkg/runtime/schema"
- ctrlfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
-
- "github.com/vmware-tanzu/velero/internal/resourcepolicies"
- "github.com/vmware-tanzu/velero/pkg/kuberesource"
-
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
corev1api "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ ctrlfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
+ "github.com/vmware-tanzu/velero/internal/resourcepolicies"
+ velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
"github.com/vmware-tanzu/velero/pkg/builder"
+ "github.com/vmware-tanzu/velero/pkg/kuberesource"
+ "github.com/vmware-tanzu/velero/pkg/util/collections"
)
func Test_resourceKey(t *testing.T) {
@@ -494,3 +494,284 @@ func TestUnTrackSkippedPV_PendingLostPVC(t *testing.T) {
})
}
}
+
+// includeAllIE is a minimal IncludesExcludesInterface that includes everything —
+// used in tests where the global resource include/exclude logic is not under test.
+type includeAllIE struct{}
+
+func (includeAllIE) ShouldInclude(string) bool { return true }
+func (includeAllIE) ShouldExclude(string) bool { return false }
+
+// makeTestUnstructured creates an unstructured object with the given namespace, name, and labels.
+func makeTestUnstructured(namespace, name string, labels map[string]string) *unstructured.Unstructured {
+ obj := &unstructured.Unstructured{}
+ obj.SetNamespace(namespace)
+ obj.SetName(name)
+ if labels != nil {
+ obj.SetLabels(labels)
+ }
+ return obj
+}
+
+// makeNameIE creates an IncludesExcludes that includes only the given glob patterns.
+func makeNameIE(include ...string) *collections.IncludesExcludes {
+ ie := collections.NewIncludesExcludes()
+ ie.Includes(include...)
+ return ie
+}
+
+// newTestItemBackupper builds a minimal itemBackupper suitable for itemInclusionChecks tests.
+func newTestItemBackupper(req *Request) *itemBackupper {
+ return &itemBackupper{
+ backupRequest: req,
+ }
+}
+
+// baseRequest returns a Request with NamespaceIncludesExcludes and ResourceIncludesExcludes
+// configured to include everything, so only the filter-map logic under test is exercised.
+func baseRequest() *Request {
+ return &Request{
+ Backup: builder.ForBackup("velero", "test-backup").Result(),
+ NamespaceIncludesExcludes: collections.NewNamespaceIncludesExcludes().Includes("*"),
+ ResourceIncludesExcludes: includeAllIE{},
+ SkippedPVTracker: NewSkipPVTracker(),
+ }
+}
+
+var configMapsGR = schema.GroupResource{Group: "", Resource: "configmaps"}
+var clusterRolesGR = schema.GroupResource{Group: "rbac.authorization.k8s.io", Resource: "clusterroles"}
+
+// TestItemInclusionChecks_ExcludeLabel_OverridesNamespaceFilter verifies that
+// velero.io/exclude-from-backup=true takes precedence over a namespacedFilterPolicies
+// entry that would otherwise include the resource.
+func TestItemInclusionChecks_ExcludeLabel_OverridesNamespaceFilter(t *testing.T) {
+ req := baseRequest()
+ req.NamespacedFilterMap = map[string]*ResolvedNamespaceFilter{
+ "ns-a": {
+ ResourceFilterMap: map[string]*ResolvedResourceFilter{
+ configMapsGR.String(): {}, // include all ConfigMaps in ns-a
+ },
+ },
+ }
+ req.NamespacedFilterPatterns = []NamespacedFilterPattern{}
+
+ ib := newTestItemBackupper(req)
+ log := logrus.New()
+
+ obj := makeTestUnstructured("ns-a", "my-config", map[string]string{
+ velerov1api.ExcludeFromBackupLabel: "true",
+ })
+
+ result := ib.itemInclusionChecks(log, false, obj, obj, configMapsGR)
+ assert.False(t, result, "resource with exclude-from-backup=true must be excluded even when matched by namespacedFilterPolicies")
+}
+
+// TestItemInclusionChecks_ExcludeLabel_OverridesCatchAll verifies that
+// velero.io/exclude-from-backup=true takes precedence over the catch-all filter.
+func TestItemInclusionChecks_ExcludeLabel_OverridesCatchAll(t *testing.T) {
+ catchAllFilter := &ResolvedResourceFilter{} // include everything via catch-all
+ req := baseRequest()
+ req.NamespacedFilterMap = map[string]*ResolvedNamespaceFilter{
+ "ns-a": {
+ ResourceFilterMap: map[string]*ResolvedResourceFilter{},
+ CatchAllFilter: catchAllFilter,
+ },
+ }
+ req.NamespacedFilterPatterns = []NamespacedFilterPattern{}
+
+ ib := newTestItemBackupper(req)
+ log := logrus.New()
+
+ obj := makeTestUnstructured("ns-a", "my-config", map[string]string{
+ velerov1api.ExcludeFromBackupLabel: "true",
+ })
+
+ result := ib.itemInclusionChecks(log, false, obj, obj, configMapsGR)
+ assert.False(t, result, "resource with exclude-from-backup=true must be excluded even when matched by catch-all filter")
+}
+
+// TestItemInclusionChecks_ExcludeLabel_OverridesClusterScopedFilter verifies that
+// velero.io/exclude-from-backup=true takes precedence over clusterScopedFilterPolicy.
+func TestItemInclusionChecks_ExcludeLabel_OverridesClusterScopedFilter(t *testing.T) {
+ req := baseRequest()
+ req.ClusterScopedFilterMap = map[string]*ResolvedResourceFilter{
+ clusterRolesGR.String(): {}, // include all ClusterRoles
+ }
+
+ ib := newTestItemBackupper(req)
+ log := logrus.New()
+
+ // Cluster-scoped object: no namespace
+ obj := makeTestUnstructured("", "my-role", map[string]string{
+ velerov1api.ExcludeFromBackupLabel: "true",
+ })
+
+ result := ib.itemInclusionChecks(log, false, obj, obj, clusterRolesGR)
+ assert.False(t, result, "cluster-scoped resource with exclude-from-backup=true must be excluded even when in clusterScopedFilterPolicy")
+}
+
+// TestItemInclusionChecks_ClusterScoped_NotInFilterMap_PassesThrough verifies that
+// a dynamically injected cluster-scoped resource NOT listed in ClusterScopedFilterMap
+// passes through itemInclusionChecks (permissive passthrough at Stage 2).
+func TestItemInclusionChecks_ClusterScoped_NotInFilterMap_PassesThrough(t *testing.T) {
+ req := baseRequest()
+ req.ClusterScopedFilterMap = map[string]*ResolvedResourceFilter{
+ clusterRolesGR.String(): {}, // only ClusterRoles are listed
+ }
+
+ ib := newTestItemBackupper(req)
+ log := logrus.New()
+
+ // VolumeSnapshotClass is NOT in the filter map
+ volumeSnapshotClassGR := schema.GroupResource{Group: "snapshot.storage.k8s.io", Resource: "volumesnapshotclasses"}
+ obj := makeTestUnstructured("", "standard", nil)
+
+ result := ib.itemInclusionChecks(log, false, obj, obj, volumeSnapshotClassGR)
+ assert.True(t, result, "cluster-scoped resource not in ClusterScopedFilterMap must pass through (permissive Stage 2 for unlisted kinds)")
+}
+
+// TestItemInclusionChecks_ClusterScoped_NameIE_Matching verifies that a cluster-scoped
+// resource listed in ClusterScopedFilterMap with a NameIE filter is included/excluded
+// based on its name.
+func TestItemInclusionChecks_ClusterScoped_NameIE_Matching(t *testing.T) {
+ req := baseRequest()
+ req.ClusterScopedFilterMap = map[string]*ResolvedResourceFilter{
+ clusterRolesGR.String(): {
+ NameIE: makeNameIE("my-app-*"),
+ },
+ }
+
+ ib := newTestItemBackupper(req)
+ log := logrus.New()
+
+ // Matching name
+ matching := makeTestUnstructured("", "my-app-reader", nil)
+ assert.True(t, ib.itemInclusionChecks(log, false, matching, matching, clusterRolesGR),
+ "ClusterRole matching name pattern must be included")
+
+ // Non-matching name
+ nonMatching := makeTestUnstructured("", "other-role", nil)
+ assert.False(t, ib.itemInclusionChecks(log, false, nonMatching, nonMatching, clusterRolesGR),
+ "ClusterRole not matching name pattern must be excluded")
+}
+
+// TestItemInclusionChecks_GlobalExclusion_OverridesNamespaceFilter verifies that
+// a resource kind globally excluded by includeExcludePolicy is rejected at Stage 2
+// even when a namespacedFilterPolicies entry lists that kind. The global
+// ResourceIncludesExcludes.ShouldInclude check fires before the per-namespace filter.
+func TestItemInclusionChecks_GlobalExclusion_OverridesNamespaceFilter(t *testing.T) {
+ // excludeSecretsIE excludes "secrets" globally, includes everything else.
+ excludeSecretsIE := &excludeResourceIE{excluded: "secrets"}
+
+ req := &Request{
+ Backup: builder.ForBackup("velero", "test-backup").Result(),
+ NamespaceIncludesExcludes: collections.NewNamespaceIncludesExcludes().Includes("*"),
+ ResourceIncludesExcludes: excludeSecretsIE,
+ SkippedPVTracker: NewSkipPVTracker(),
+ // namespacedFilterPolicies says to back up Secrets in ns-a
+ NamespacedFilterMap: map[string]*ResolvedNamespaceFilter{
+ "ns-a": {
+ ResourceFilterMap: map[string]*ResolvedResourceFilter{
+ "secrets.": {}, // Secret listed in per-namespace filter
+ },
+ },
+ },
+ NamespacedFilterPatterns: []NamespacedFilterPattern{},
+ }
+
+ ib := newTestItemBackupper(req)
+ log := logrus.New()
+
+ secretsGR := schema.GroupResource{Group: "", Resource: "secrets"}
+ obj := makeTestUnstructured("ns-a", "my-secret", nil)
+
+ result := ib.itemInclusionChecks(log, false, obj, obj, secretsGR)
+ assert.False(t, result,
+ "Secret must be excluded because it is globally excluded by ResourceIncludesExcludes, "+
+ "even though namespacedFilterPolicies lists it")
+}
+
+// TestItemInclusionChecks_PluginItem_UnlistedKind_NoCatchAll_PassesThrough verifies the
+// intentional permissive passthrough at Stage 2 for plugin-injected additional items.
+// When a namespace has a namespacedFilterPolicies entry but the item's kind is not listed
+// in that policy and there is no catch-all entry, itemInclusionChecks must still allow
+// the item through.
+//
+// Rationale: plugin-injected additional items (returned by BackupItemAction) must be able
+// to reach the archive even when their kind was not explicitly listed in the filter policy,
+// because rejecting them here would break backup completeness. For example, a CSI plugin
+// may inject a VolumeSnapshotContent that is required for a correct restore.
+// Kind-level exclusion for the primary collection pass is enforced at Stage 1 in
+// item_collector.go, not at Stage 2 here.
+func TestItemInclusionChecks_PluginItem_UnlistedKind_NoCatchAll_PassesThrough(t *testing.T) {
+ req := baseRequest()
+ // Namespace filter only lists ConfigMaps; Secrets are not listed and there is no catch-all.
+ req.NamespacedFilterMap = map[string]*ResolvedNamespaceFilter{
+ "ns-a": {
+ ResourceFilterMap: map[string]*ResolvedResourceFilter{
+ configMapsGR.String(): {},
+ },
+ CatchAllFilter: nil,
+ },
+ }
+ req.NamespacedFilterPatterns = []NamespacedFilterPattern{}
+
+ ib := newTestItemBackupper(req)
+ log := logrus.New()
+
+ secretsGR := schema.GroupResource{Group: "", Resource: "secrets"}
+ obj := makeTestUnstructured("ns-a", "plugin-injected-secret", nil)
+
+ result := ib.itemInclusionChecks(log, false, obj, obj, secretsGR)
+ assert.True(t, result,
+ "plugin-injected additional item of an unlisted kind must pass through Stage 2 "+
+ "even when its namespace has a namespacedFilterPolicies entry with no catch-all; "+
+ "kind exclusion is enforced at Stage 1 (item_collector.go), not here")
+}
+
+// TestItemInclusionChecks_PluginItem_UnlistedKind_WithCatchAll_PassesThrough verifies that
+// a plugin-injected additional item of a kind not listed in the namespace filter also passes
+// through Stage 2 when a catch-all entry is present. The catch-all is validated to never
+// carry a NameIE (names/excludedNames are prohibited on catch-all entries), so the name
+// check is always a no-op for catch-all-matched items and the item is included.
+func TestItemInclusionChecks_PluginItem_UnlistedKind_WithCatchAll_PassesThrough(t *testing.T) {
+ req := baseRequest()
+ // Namespace filter lists ConfigMaps explicitly; a catch-all covers everything else.
+ // The catch-all has no NameIE — this is enforced by validation.
+ req.NamespacedFilterMap = map[string]*ResolvedNamespaceFilter{
+ "ns-a": {
+ ResourceFilterMap: map[string]*ResolvedResourceFilter{
+ configMapsGR.String(): {},
+ },
+ CatchAllFilter: &ResolvedResourceFilter{
+ // NameIE intentionally nil: validation forbids names/excludedNames on catch-all
+ NameIE: nil,
+ },
+ },
+ }
+ req.NamespacedFilterPatterns = []NamespacedFilterPattern{}
+
+ ib := newTestItemBackupper(req)
+ log := logrus.New()
+
+ secretsGR := schema.GroupResource{Group: "", Resource: "secrets"}
+ obj := makeTestUnstructured("ns-a", "plugin-injected-secret", nil)
+
+ result := ib.itemInclusionChecks(log, false, obj, obj, secretsGR)
+ assert.True(t, result,
+ "plugin-injected additional item matched by catch-all must pass through Stage 2; "+
+ "the catch-all has no NameIE so the name check is a no-op")
+}
+
+// excludeResourceIE is an IncludesExcludesInterface that excludes a single resource
+// type and includes everything else. Used to simulate includeExcludePolicy global exclusions.
+type excludeResourceIE struct {
+ excluded string
+}
+
+func (e *excludeResourceIE) ShouldInclude(typeName string) bool {
+ return typeName != e.excluded
+}
+func (e *excludeResourceIE) ShouldExclude(typeName string) bool {
+ return typeName == e.excluded
+}
diff --git a/pkg/backup/item_collector.go b/pkg/backup/item_collector.go
index b9e6ae313..4c3d6e275 100644
--- a/pkg/backup/item_collector.go
+++ b/pkg/backup/item_collector.go
@@ -462,6 +462,7 @@ func (r *itemCollector) getResourceItems(
}
clusterScoped := !resource.Namespaced
+
namespacesToList := getNamespacesToList(r.backupRequest.NamespaceIncludesExcludes)
// If we get here, we're backing up something other than namespaces
@@ -472,6 +473,16 @@ func (r *itemCollector) getResourceItems(
var items []*kubernetesResource
for _, namespace := range namespacesToList {
+ // Check per-namespace resource type filter from ResourcePolicy
+ if nsFilter := r.backupRequest.GetNamespaceFilter(namespace); nsFilter != nil {
+ _, hasSpecific := nsFilter.ResourceFilterMap[gr.String()]
+ if !hasSpecific && nsFilter.CatchAllFilter == nil {
+ log.Debugf("Skipping resource %s in namespace %s: not in resourceFilters",
+ gr, namespace)
+ continue
+ }
+ }
+
unstructuredItems, err := r.listResourceByLabelsPerNamespace(
namespace, gr, gv, resource, log)
if err != nil {
@@ -528,13 +539,55 @@ func (r *itemCollector) listResourceByLabelsPerNamespace(
return nil, err
}
+ // 1. Start with global selectors (existing default behavior)
var orLabelSelectors []string
+ var labelSelector string
+
if r.backupRequest.Spec.OrLabelSelectors != nil {
for _, s := range r.backupRequest.Spec.OrLabelSelectors {
orLabelSelectors = append(orLabelSelectors, metav1.FormatLabelSelector(s))
}
- } else {
- orLabelSelectors = []string{}
+ }
+ if selector := r.backupRequest.Spec.LabelSelector; selector != nil {
+ labelSelector = metav1.FormatLabelSelector(selector)
+ }
+
+ // 2. Apply fine-grained filter overrides if applicable
+ if !resource.Namespaced && r.backupRequest.ClusterScopedFilterMap != nil {
+ if rf := r.backupRequest.ClusterScopedFilterMap[gr.String()]; rf != nil {
+ // Overwrite global selectors with specific filter
+ orLabelSelectors = nil
+ labelSelector = ""
+ if rf.LabelSelector != nil {
+ labelSelector = rf.LabelSelector.String()
+ }
+ for _, s := range rf.OrLabelSelectors {
+ orLabelSelectors = append(orLabelSelectors, s.String())
+ }
+ }
+ // ClusterScopedFilterPolicy: If rf == nil, it intentionally falls back to the global selectors initialized above
+ } else if nsFilter := r.backupRequest.GetNamespaceFilter(namespace); nsFilter != nil {
+ rf := nsFilter.ResourceFilterMap[gr.String()]
+ if rf == nil {
+ rf = nsFilter.CatchAllFilter
+ }
+
+ if rf != nil {
+ // Overwrite global selectors with specific filter
+ orLabelSelectors = nil
+ labelSelector = ""
+ if rf.LabelSelector != nil {
+ labelSelector = rf.LabelSelector.String()
+ }
+ for _, s := range rf.OrLabelSelectors {
+ orLabelSelectors = append(orLabelSelectors, s.String())
+ }
+ } else {
+ // NamespacedFilterPolicies: namespacedFilterPolicies acts as an exclusive allowlist.
+ // If neither a kind-specific entry nor a catch-all entry exists, skip the kind.
+ logger.Debug("Skipping resource kind for namespace as it is not present in the namespace filter policy")
+ return nil, nil
+ }
}
logger.Info("Listing items")
@@ -554,11 +607,6 @@ func (r *itemCollector) listResourceByLabelsPerNamespace(
return nil, err
}
- var labelSelector string
- if selector := r.backupRequest.Spec.LabelSelector; selector != nil {
- labelSelector = metav1.FormatLabelSelector(selector)
- }
-
// Listing items for labelSelector (singular)
if len(orLabelSelectors) == 0 {
unstructuredItems, err = r.listItemsForLabel(
diff --git a/pkg/backup/item_collector_test.go b/pkg/backup/item_collector_test.go
index 54e2ed4c3..084d5b5ff 100644
--- a/pkg/backup/item_collector_test.go
+++ b/pkg/backup/item_collector_test.go
@@ -26,7 +26,9 @@ import (
corev1api "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
"github.com/vmware-tanzu/velero/pkg/builder"
@@ -279,8 +281,9 @@ func TestItemCollectorBackupNamespaces(t *testing.T) {
Backup: tc.backup,
NamespaceIncludesExcludes: tc.ie,
},
- dynamicFactory: factory,
- dir: tempDir,
+ dynamicFactory: factory,
+ discoveryHelper: test.NewFakeDiscoveryHelper(true, nil),
+ dir: tempDir,
}
if tc.converter == nil {
@@ -305,3 +308,140 @@ func TestItemCollectorBackupNamespaces(t *testing.T) {
})
}
}
+
+// TestNamespacedFilterMap_GlobalExclusionPrecedence verifies the precedence rule:
+// ResourceIncludesExcludes (set by includeExcludePolicy) is checked before the
+// NamespacedFilterMap. This is enforced at both Stage 1 (item_collector.go line ~430)
+// and Stage 2 (item_backupper.go itemInclusionChecks). The unit below confirms that
+// GetNamespaceFilter still returns a filter for the namespace — it is the caller's
+// responsibility to check ResourceIncludesExcludes first, which item_collector does.
+//
+// Full coverage of the Stage 2 enforcement is in item_backupper_test.go
+// TestItemInclusionChecks_GlobalExclusion_OverridesNamespaceFilter.
+func TestNamespacedFilterMap_GlobalExclusionPrecedence(t *testing.T) {
+ req := &Request{
+ Backup: builder.ForBackup("velero", "test-backup").Result(),
+ NamespaceIncludesExcludes: collections.NewNamespaceIncludesExcludes().Includes("ns-a"),
+ NamespacedFilterMap: map[string]*ResolvedNamespaceFilter{
+ "ns-a": {
+ ResourceFilterMap: map[string]*ResolvedResourceFilter{
+ "secrets.": {},
+ },
+ },
+ },
+ NamespacedFilterPatterns: []NamespacedFilterPattern{},
+ }
+
+ // GetNamespaceFilter returns the filter regardless of global exclusions.
+ // The caller (item_collector) is responsible for checking ResourceIncludesExcludes first.
+ nsFilter := req.GetNamespaceFilter("ns-a")
+ require.NotNil(t, nsFilter, "GetNamespaceFilter should return a filter for ns-a")
+ _, hasSecrets := nsFilter.ResourceFilterMap["secrets."]
+ assert.True(t, hasSecrets, "ns-a filter should list secrets GR")
+
+ // When a global excludeAllIE is set, item_collector would return nil before consulting the map.
+ // This is verified by the Stage 1 check: ShouldInclude("secrets.") == false → skip.
+ ie := &excludeAllIE{}
+ assert.False(t, ie.ShouldInclude("secrets."),
+ "global exclusion must reject secrets before the per-namespace filter is consulted")
+}
+
+// excludeAllIE is an IncludesExcludesInterface that excludes every resource kind.
+type excludeAllIE struct{}
+
+func (excludeAllIE) ShouldInclude(string) bool { return false }
+func (excludeAllIE) ShouldExclude(string) bool { return true }
+
+func TestGetResourceItems(t *testing.T) {
+ tests := []struct {
+ name string
+ namespaces []string
+ clusterScopedFilterMap map[string]*ResolvedResourceFilter
+ namespacedFilterMap map[string]*ResolvedNamespaceFilter
+ resource metav1.APIResource
+ gr schema.GroupResource
+ }{
+ {
+ name: "cluster scoped resource with filter",
+ namespaces: []string{""},
+ resource: metav1.APIResource{
+ Name: "persistentvolumes",
+ Namespaced: false,
+ },
+ gr: schema.GroupResource{Resource: "persistentvolumes"},
+ clusterScopedFilterMap: map[string]*ResolvedResourceFilter{
+ "persistentvolumes": {
+ LabelSelector: labels.Set{"app": "foo"}.AsSelector(),
+ },
+ },
+ },
+ {
+ name: "namespace scoped resource with filter",
+ namespaces: []string{"ns1"},
+ resource: metav1.APIResource{
+ Name: "pods",
+ Namespaced: true,
+ },
+ gr: schema.GroupResource{Resource: "pods"},
+ namespacedFilterMap: map[string]*ResolvedNamespaceFilter{
+ "ns1": {
+ ResourceFilterMap: map[string]*ResolvedResourceFilter{
+ "pods": {
+ LabelSelector: labels.Set{"app": "bar"}.AsSelector(),
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "namespace scoped resource skipped due to no filter match",
+ namespaces: []string{"ns1"},
+ resource: metav1.APIResource{
+ Name: "secrets",
+ Namespaced: true,
+ },
+ gr: schema.GroupResource{Resource: "secrets"},
+ namespacedFilterMap: map[string]*ResolvedNamespaceFilter{
+ "ns1": {
+ ResourceFilterMap: map[string]*ResolvedResourceFilter{
+ "pods": {
+ LabelSelector: labels.Set{"app": "bar"}.AsSelector(),
+ },
+ },
+ },
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ dc := &test.FakeDynamicClient{}
+ dc.On("List", mock.Anything).Return(&unstructured.UnstructuredList{}, nil)
+
+ factory := &test.FakeDynamicFactory{}
+ factory.On("ClientForGroupVersionResource", mock.Anything, mock.Anything, mock.Anything).Return(dc, nil)
+
+ req := &Request{
+ Backup: builder.ForBackup("velero", "backup").Result(),
+ ClusterScopedFilterMap: tc.clusterScopedFilterMap,
+ NamespacedFilterMap: tc.namespacedFilterMap,
+ ResourceIncludesExcludes: includeAllIE{},
+ }
+ if len(tc.namespaces) > 0 && tc.namespaces[0] != "" {
+ req.NamespaceIncludesExcludes = collections.NewNamespaceIncludesExcludes().Includes(tc.namespaces...)
+ } else {
+ req.NamespaceIncludesExcludes = collections.NewNamespaceIncludesExcludes().Includes("*")
+ }
+
+ r := &itemCollector{
+ backupRequest: req,
+ dynamicFactory: factory,
+ discoveryHelper: test.NewFakeDiscoveryHelper(true, nil),
+ log: test.NewLogger(),
+ }
+
+ _, err := r.getResourceItems(test.NewLogger(), schema.GroupVersion{}, tc.resource, nil)
+ assert.NoError(t, err)
+ })
+ }
+}
diff --git a/pkg/backup/request.go b/pkg/backup/request.go
index eb9edcbe8..7ace38125 100644
--- a/pkg/backup/request.go
+++ b/pkg/backup/request.go
@@ -19,6 +19,9 @@ package backup
import (
"sync"
+ "github.com/gobwas/glob"
+ "k8s.io/apimachinery/pkg/labels"
+
"github.com/vmware-tanzu/velero/internal/hook"
"github.com/vmware-tanzu/velero/internal/resourcepolicies"
"github.com/vmware-tanzu/velero/internal/volume"
@@ -34,6 +37,21 @@ type itemKey struct {
name string
}
+// ResolvedResourceFilter holds the materialized filter state for one kind-group
+// within a namespace.
+type ResolvedResourceFilter struct {
+ LabelSelector labels.Selector
+ OrLabelSelectors []labels.Selector
+ NameIE *collections.IncludesExcludes
+}
+
+// ResolvedNamespaceFilter holds the materialized filter state for a namespace.
+// ResourceFilterMap is keyed by the resolved group-resource string.
+type ResolvedNamespaceFilter struct {
+ ResourceFilterMap map[string]*ResolvedResourceFilter
+ CatchAllFilter *ResolvedResourceFilter
+}
+
type SynchronizedVSList struct {
sync.Mutex
VolumeSnapshotList []*volume.Snapshot
@@ -70,6 +88,31 @@ type Request struct {
SkippedPVTracker *skipPVTracker
VolumesInformation volume.BackupVolumesInformation
WorkerPool *ItemBlockWorkerPool
+
+ // ClusterScopedFilterMap holds resolved global filters for cluster-scoped resources.
+ // Key is the resolved group-resource string.
+ ClusterScopedFilterMap map[string]*ResolvedResourceFilter
+
+ // NamespacedFilterMap holds resolved per-namespace filters.
+ // Key is either an exact namespace name or a glob pattern.
+ NamespacedFilterMap map[string]*ResolvedNamespaceFilter
+
+ // NamespacedFilterPatterns preserves the order of patterns for first-match semantics
+ // and caches pre-compiled globs to avoid repeated compilation in the hot path.
+ NamespacedFilterPatterns []NamespacedFilterPattern
+
+ // NamespaceFilterCache memoizes the resolved filter for a given namespace.
+ // sync.Map is used because item backuppers access this concurrently.
+ NamespaceFilterCache sync.Map
+}
+
+// NamespacedFilterPattern pairs a namespace pattern string with its pre-compiled
+// glob so that GetNamespaceFilter does not recompile on every call.
+// Compiled is nil for exact-match (non-glob) patterns, which are looked up
+// directly in NamespacedFilterMap.
+type NamespacedFilterPattern struct {
+ Pattern string
+ Compiled glob.Glob
}
// BackupVolumesInformation contains the information needs by generating
@@ -107,3 +150,40 @@ func (r *Request) FillVolumesInformation() {
func (r *Request) StopWorkerPool() {
r.WorkerPool.Stop()
}
+
+// GetNamespaceFilter returns the resolved filter for a namespace, or nil
+// if the namespace should use global filters. Uses first-match semantics
+// when multiple patterns could match the same namespace, but exact matches
+// always take precedence over glob patterns regardless of definition order.
+func (r *Request) GetNamespaceFilter(namespace string) *ResolvedNamespaceFilter {
+ if r.NamespacedFilterMap == nil {
+ return nil
+ }
+
+ // 1. Check the concurrent cache first
+ if val, ok := r.NamespaceFilterCache.Load(namespace); ok {
+ if val == nil {
+ return nil
+ }
+ return val.(*ResolvedNamespaceFilter)
+ }
+
+ // 2. Check for exact match first
+ if f, ok := r.NamespacedFilterMap[namespace]; ok {
+ r.NamespaceFilterCache.Store(namespace, f)
+ return f
+ }
+
+ // 3. Walk patterns in definition order using pre-compiled globs
+ for _, p := range r.NamespacedFilterPatterns {
+ if p.Compiled != nil && p.Compiled.Match(namespace) {
+ filter := r.NamespacedFilterMap[p.Pattern]
+ r.NamespaceFilterCache.Store(namespace, filter)
+ return filter
+ }
+ }
+
+ // 4. Cache the miss
+ r.NamespaceFilterCache.Store(namespace, nil)
+ return nil
+}
diff --git a/pkg/cmd/util/output/backup_describer.go b/pkg/cmd/util/output/backup_describer.go
index db9ce5f17..89fc74a70 100644
--- a/pkg/cmd/util/output/backup_describer.go
+++ b/pkg/cmd/util/output/backup_describer.go
@@ -21,6 +21,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "io"
"sort"
"strconv"
"strings"
@@ -30,6 +31,7 @@ import (
"github.com/cockroachdb/errors"
snapshotv1api "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1"
+ "github.com/sirupsen/logrus"
"github.com/fatih/color"
kbclient "sigs.k8s.io/controller-runtime/pkg/client"
@@ -40,6 +42,7 @@ import (
"github.com/vmware-tanzu/velero/pkg/cmd/util/downloadrequest"
"github.com/vmware-tanzu/velero/pkg/itemoperation"
+ "github.com/vmware-tanzu/velero/internal/resourcepolicies"
"github.com/vmware-tanzu/velero/internal/volume"
"github.com/vmware-tanzu/velero/pkg/util/collections"
"github.com/vmware-tanzu/velero/pkg/util/results"
@@ -91,6 +94,9 @@ func DescribeBackup(
if backup.Spec.ResourcePolicy != nil {
d.Println()
DescribeResourcePolicies(d, backup.Spec.ResourcePolicy)
+
+ // Display fine-grained filter policies if they exist
+ DescribeFineGrainedFilterPolicies(ctx, kbClient, d, backup)
}
if backup.Spec.UploaderConfig != nil && backup.Spec.UploaderConfig.ParallelFilesUpload > 0 {
@@ -130,6 +136,119 @@ func DescribeResourcePolicies(d *Describer, resPolicies *corev1api.TypedLocalObj
d.Printf("\tName:\t%s\n", resPolicies.Name)
}
+// DescribeFineGrainedFilterPolicies describes cluster-scoped and namespace-scoped filter policies if present
+func DescribeFineGrainedFilterPolicies(ctx context.Context, kbClient kbclient.Client, d *Describer, backup *velerov1api.Backup) {
+ if backup.Spec.ResourcePolicy == nil {
+ return
+ }
+
+ // Create a discard logger for the resource policies function since this is CLI output context
+ discardLogger := logrus.New()
+ discardLogger.Out = io.Discard
+
+ resourcePolicies, err := resourcepolicies.GetResourcePoliciesFromBackup(*backup, kbClient, discardLogger)
+ if err != nil {
+ // Don't fail the describe if we can't read policies, just skip
+ return
+ }
+
+ if resourcePolicies == nil {
+ return
+ }
+
+ clusterScopedFilterPolicy := resourcePolicies.GetClusterScopedFilterPolicy()
+ if clusterScopedFilterPolicy != nil {
+ d.Printf("\nCluster Scoped Filter Policy:\n")
+ d.Printf(" Resource Filters:\n")
+ for _, rf := range clusterScopedFilterPolicy.ResourceFilters {
+ kindsStr := strings.Join(rf.Kinds, ", ")
+ d.Printf(" %s:\n", kindsStr)
+
+ // Label selector
+ if len(rf.LabelSelector) > 0 {
+ selectorStr := formatLabelMap(rf.LabelSelector)
+ d.Printf(" Label selector: %s\n", selectorStr)
+ } else if len(rf.OrLabelSelectors) > 0 {
+ var orStrs []string
+ for _, ols := range rf.OrLabelSelectors {
+ orStrs = append(orStrs, formatLabelMap(ols))
+ }
+ d.Printf(" OR label selectors: [%s]\n", strings.Join(orStrs, ", "))
+ } else {
+ d.Printf(" Label selector: \n")
+ }
+
+ // Name patterns
+ if len(rf.Names) > 0 {
+ d.Printf(" Included names: [%s]\n", strings.Join(rf.Names, ", "))
+ } else {
+ d.Printf(" Included names: \n")
+ }
+
+ if len(rf.ExcludedNames) > 0 {
+ d.Printf(" Excluded names: [%s]\n", strings.Join(rf.ExcludedNames, ", "))
+ } else {
+ d.Printf(" Excluded names: \n")
+ }
+ }
+ }
+
+ nfPolicies := resourcePolicies.GetNamespacedFilterPolicies()
+ if len(nfPolicies) > 0 {
+ d.Printf("\nNamespace-Scoped Filter Policies:\n")
+ for _, policy := range nfPolicies {
+ for _, ns := range policy.Namespaces {
+ d.Printf(" %s:\n", ns)
+ d.Printf(" Resource Filters:\n")
+ for _, rf := range policy.ResourceFilters {
+ var kindsStr string
+ if rf.IsCatchAll() {
+ kindsStr = " (all other kinds)"
+ } else {
+ kindsStr = strings.Join(rf.Kinds, ", ")
+ }
+ d.Printf(" %s:\n", kindsStr)
+
+ // Label selector
+ if len(rf.LabelSelector) > 0 {
+ selectorStr := formatLabelMap(rf.LabelSelector)
+ d.Printf(" Label selector: %s\n", selectorStr)
+ } else if len(rf.OrLabelSelectors) > 0 {
+ var orStrs []string
+ for _, ols := range rf.OrLabelSelectors {
+ orStrs = append(orStrs, formatLabelMap(ols))
+ }
+ d.Printf(" OR label selectors: [%s]\n", strings.Join(orStrs, ", "))
+ } else {
+ d.Printf(" Label selector: \n")
+ }
+
+ // Name patterns
+ if len(rf.Names) > 0 {
+ d.Printf(" Included names: [%s]\n", strings.Join(rf.Names, ", "))
+ } else {
+ d.Printf(" Included names: \n")
+ }
+
+ if len(rf.ExcludedNames) > 0 {
+ d.Printf(" Excluded names: [%s]\n", strings.Join(rf.ExcludedNames, ", "))
+ } else {
+ d.Printf(" Excluded names: \n")
+ }
+ }
+ }
+ }
+ }
+}
+
+func formatLabelMap(labelMap map[string]string) string {
+ var pairs []string
+ for k, v := range labelMap {
+ pairs = append(pairs, fmt.Sprintf("%s=%s", k, v))
+ }
+ return strings.Join(pairs, ",")
+}
+
// DescribeUploaderConfigForBackup describes uploader config in human-readable format
func DescribeUploaderConfigForBackup(d *Describer, spec velerov1api.BackupSpec) {
d.Printf("Uploader config:\n")
diff --git a/pkg/cmd/util/output/backup_describer_test.go b/pkg/cmd/util/output/backup_describer_test.go
index 0de03bdaa..936b19422 100644
--- a/pkg/cmd/util/output/backup_describer_test.go
+++ b/pkg/cmd/util/output/backup_describer_test.go
@@ -18,6 +18,7 @@ package output
import (
"bytes"
+ "context"
"testing"
"text/tabwriter"
"time"
@@ -25,6 +26,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1api "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
"github.com/vmware-tanzu/velero/internal/volume"
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
@@ -866,3 +869,85 @@ func TestDescribeBackupItemOperation(t *testing.T) {
d.out.Flush()
assert.Equal(t, expected, d.buf.String())
}
+
+func TestDescribeFineGrainedFilterPolicies(t *testing.T) {
+ yamlData := `
+version: v1
+clusterScopedFilterPolicy:
+ resourceFilters:
+ - kinds: ["StorageClass"]
+ labelSelector: {"app": "velero"}
+ - kinds: ["ClusterRole"]
+ orLabelSelectors:
+ - {"app": "velero"}
+ - {"app": "test"}
+ names: ["role1"]
+ excludedNames: ["role2"]
+namespacedFilterPolicies:
+- namespaces: ["ns1", "ns2"]
+ resourceFilters:
+ - kinds: ["Pod", "ConfigMap"]
+ labelSelector: {"app": "velero"}
+ - kinds: ["*"]
+`
+ cm := &corev1api.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-policy",
+ Namespace: "velero",
+ },
+ Data: map[string]string{
+ "policy.yaml": yamlData,
+ },
+ }
+
+ client := fake.NewClientBuilder().WithRuntimeObjects(cm).Build()
+
+ backup := builder.ForBackup("velero", "test-backup").
+ ResourcePolicies("test-policy").Result()
+
+ d := &Describer{
+ Prefix: "",
+ out: &tabwriter.Writer{},
+ buf: &bytes.Buffer{},
+ }
+ d.out.Init(d.buf, 0, 8, 2, ' ', 0)
+
+ DescribeFineGrainedFilterPolicies(context.Background(), client, d, backup)
+ d.out.Flush()
+
+ expected := `
+Cluster Scoped Filter Policy:
+ Resource Filters:
+ StorageClass:
+ Label selector: app=velero
+ Included names:
+ Excluded names:
+ ClusterRole:
+ OR label selectors: [app=velero, app=test]
+ Included names: [role1]
+ Excluded names: [role2]
+
+Namespace-Scoped Filter Policies:
+ ns1:
+ Resource Filters:
+ Pod, ConfigMap:
+ Label selector: app=velero
+ Included names:
+ Excluded names:
+ (all other kinds):
+ Label selector:
+ Included names:
+ Excluded names:
+ ns2:
+ Resource Filters:
+ Pod, ConfigMap:
+ Label selector: app=velero
+ Included names:
+ Excluded names:
+ (all other kinds):
+ Label selector:
+ Included names:
+ Excluded names:
+`
+ assert.Equal(t, expected, d.buf.String())
+}
diff --git a/pkg/cmd/util/output/backup_structured_describer.go b/pkg/cmd/util/output/backup_structured_describer.go
index 904afa34e..8ec31b72c 100644
--- a/pkg/cmd/util/output/backup_structured_describer.go
+++ b/pkg/cmd/util/output/backup_structured_describer.go
@@ -21,13 +21,16 @@ import (
"context"
"encoding/json"
"fmt"
+ "io"
"strings"
+ "github.com/sirupsen/logrus"
corev1api "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kbclient "sigs.k8s.io/controller-runtime/pkg/client"
+ "github.com/vmware-tanzu/velero/internal/resourcepolicies"
"github.com/vmware-tanzu/velero/internal/volume"
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
"github.com/vmware-tanzu/velero/pkg/cmd/util/cacert"
@@ -54,6 +57,7 @@ func DescribeBackupInSF(
if backup.Spec.ResourcePolicy != nil {
DescribeResourcePoliciesInSF(d, backup.Spec.ResourcePolicy)
+ DescribeFineGrainedFilterPoliciesInSF(ctx, kbClient, d, backup)
}
status := backup.Status
@@ -222,6 +226,88 @@ func DescribeBackupSpecInSF(d *StructuredDescriber, spec velerov1api.BackupSpec)
d.Describe("spec", backupSpecInfo)
}
+// DescribeFineGrainedFilterPoliciesInSF adds the clusterScopedFilterPolicy
+// and namespacedFilterPolicies sections to the structured describer output when present
+// in the ResourcePolicy ConfigMap referenced by the backup.
+func DescribeFineGrainedFilterPoliciesInSF(ctx context.Context, kbClient kbclient.Client, d *StructuredDescriber, backup *velerov1api.Backup) {
+ if backup.Spec.ResourcePolicy == nil {
+ return
+ }
+
+ discardLogger := logrus.New()
+ discardLogger.Out = io.Discard
+
+ resPolicies, err := resourcepolicies.GetResourcePoliciesFromBackup(*backup, kbClient, discardLogger)
+ if err != nil || resPolicies == nil {
+ return
+ }
+
+ clusterScopedFilterPolicy := resPolicies.GetClusterScopedFilterPolicy()
+ if clusterScopedFilterPolicy != nil {
+ var clusterScopedFilters []map[string]any
+ for _, rf := range clusterScopedFilterPolicy.ResourceFilters {
+ entry := map[string]any{
+ "kinds": rf.Kinds,
+ }
+ if len(rf.LabelSelector) > 0 {
+ entry["labelSelector"] = rf.LabelSelector
+ }
+ if len(rf.OrLabelSelectors) > 0 {
+ entry["orLabelSelectors"] = rf.OrLabelSelectors
+ }
+ if len(rf.Names) > 0 {
+ entry["names"] = rf.Names
+ }
+ if len(rf.ExcludedNames) > 0 {
+ entry["excludedNames"] = rf.ExcludedNames
+ }
+ clusterScopedFilters = append(clusterScopedFilters, entry)
+ }
+ d.Describe("clusterScopedFilterPolicy", map[string]any{
+ "resourceFilters": clusterScopedFilters,
+ })
+ }
+
+ nfPolicies := resPolicies.GetNamespacedFilterPolicies()
+ if len(nfPolicies) == 0 {
+ return
+ }
+
+ var structuredPolicies []map[string]any
+ for _, policy := range nfPolicies {
+ for _, ns := range policy.Namespaces {
+ var rfEntries []map[string]any
+ for _, rf := range policy.ResourceFilters {
+ entry := map[string]any{}
+ if rf.IsCatchAll() {
+ entry["kinds"] = []string{}
+ entry["isCatchAll"] = true
+ } else {
+ entry["kinds"] = rf.Kinds
+ }
+ if len(rf.LabelSelector) > 0 {
+ entry["labelSelector"] = rf.LabelSelector
+ }
+ if len(rf.OrLabelSelectors) > 0 {
+ entry["orLabelSelectors"] = rf.OrLabelSelectors
+ }
+ if len(rf.Names) > 0 {
+ entry["names"] = rf.Names
+ }
+ if len(rf.ExcludedNames) > 0 {
+ entry["excludedNames"] = rf.ExcludedNames
+ }
+ rfEntries = append(rfEntries, entry)
+ }
+ structuredPolicies = append(structuredPolicies, map[string]any{
+ "namespace": ns,
+ "resourceFilters": rfEntries,
+ })
+ }
+ }
+ d.Describe("namespacedFilterPolicies", structuredPolicies)
+}
+
// DescribeBackupStatusInSF describes a backup status in structured format.
func DescribeBackupStatusInSF(ctx context.Context, kbClient kbclient.Client, d *StructuredDescriber, backup *velerov1api.Backup, details bool,
insecureSkipTLSVerify bool, caCertPath string, podVolumeBackups []velerov1api.PodVolumeBackup) {
diff --git a/pkg/cmd/util/output/backup_structured_describer_test.go b/pkg/cmd/util/output/backup_structured_describer_test.go
index c5ede1b36..77d219f49 100644
--- a/pkg/cmd/util/output/backup_structured_describer_test.go
+++ b/pkg/cmd/util/output/backup_structured_describer_test.go
@@ -17,6 +17,7 @@ limitations under the License.
package output
import (
+ "context"
"reflect"
"testing"
"time"
@@ -24,6 +25,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1api "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
"github.com/vmware-tanzu/velero/internal/volume"
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
@@ -707,3 +710,96 @@ func TestDescribeDeleteBackupRequestsInSF(t *testing.T) {
})
}
}
+
+func TestDescribeFineGrainedFilterPoliciesInSF(t *testing.T) {
+ yamlData := `
+version: v1
+clusterScopedFilterPolicy:
+ resourceFilters:
+ - kinds: ["StorageClass"]
+ labelSelector: {"app": "velero"}
+ - kinds: ["ClusterRole"]
+ orLabelSelectors:
+ - {"app": "velero"}
+ - {"app": "test"}
+ names: ["role1"]
+ excludedNames: ["role2"]
+namespacedFilterPolicies:
+- namespaces: ["ns1", "ns2"]
+ resourceFilters:
+ - kinds: ["Pod", "ConfigMap"]
+ labelSelector: {"app": "velero"}
+ - kinds: ["*"]
+`
+ cm := &corev1api.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-policy",
+ Namespace: "velero",
+ },
+ Data: map[string]string{
+ "policy.yaml": yamlData,
+ },
+ }
+
+ client := fake.NewClientBuilder().WithRuntimeObjects(cm).Build()
+
+ backup := builder.ForBackup("velero", "test-backup").
+ ResourcePolicies("test-policy").Result()
+
+ sd := &StructuredDescriber{
+ output: make(map[string]any),
+ format: "",
+ }
+
+ DescribeFineGrainedFilterPoliciesInSF(context.Background(), client, sd, backup)
+
+ expect := map[string]any{
+ "clusterScopedFilterPolicy": map[string]any{
+ "resourceFilters": []map[string]any{
+ {
+ "kinds": []string{"StorageClass"},
+ "labelSelector": map[string]string{"app": "velero"},
+ },
+ {
+ "kinds": []string{"ClusterRole"},
+ "orLabelSelectors": []map[string]string{
+ {"app": "velero"},
+ {"app": "test"},
+ },
+ "names": []string{"role1"},
+ "excludedNames": []string{"role2"},
+ },
+ },
+ },
+ "namespacedFilterPolicies": []map[string]any{
+ {
+ "namespace": "ns1",
+ "resourceFilters": []map[string]any{
+ {
+ "kinds": []string{"Pod", "ConfigMap"},
+ "labelSelector": map[string]string{"app": "velero"},
+ },
+ {
+ "kinds": []string{},
+ "isCatchAll": true,
+ },
+ },
+ },
+ {
+ "namespace": "ns2",
+ "resourceFilters": []map[string]any{
+ {
+ "kinds": []string{"Pod", "ConfigMap"},
+ "labelSelector": map[string]string{"app": "velero"},
+ },
+ {
+ "kinds": []string{},
+ "isCatchAll": true,
+ },
+ },
+ },
+ },
+ }
+
+ assert.True(t, reflect.DeepEqual(sd.output, expect))
+}
diff --git a/pkg/controller/backup_controller.go b/pkg/controller/backup_controller.go
index d638fc460..7bb9ee5f9 100644
--- a/pkg/controller/backup_controller.go
+++ b/pkg/controller/backup_controller.go
@@ -603,6 +603,13 @@ 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 include-exclude policies.")
}
+ // namespacedFilterPolicies and clusterScopedFilterPolicy incompatible with old-style filters
+ if resourcePolicies != nil &&
+ (len(resourcePolicies.GetNamespacedFilterPolicies()) > 0 || resourcePolicies.GetClusterScopedFilterPolicy() != nil) &&
+ collections.UseOldResourceFilters(request.Spec) {
+ 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.")
+ }
request.ResPolicies = resourcePolicies
return request
}
diff --git a/pkg/controller/backup_controller_test.go b/pkg/controller/backup_controller_test.go
index 9f45e829e..0ff76cf63 100644
--- a/pkg/controller/backup_controller_test.go
+++ b/pkg/controller/backup_controller_test.go
@@ -21,6 +21,7 @@ import (
"fmt"
"io"
"reflect"
+ "slices"
"sort"
"strings"
"testing"
@@ -33,6 +34,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
+ corev1api "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
@@ -2019,3 +2021,237 @@ func TestPatchResourceWorksWithStatus(t *testing.T) {
})
}
}
+
+// TestPrepareBackupRequest_NamespacedFilterPoliciesIncompatibleWithOldFilters verifies
+// that a backup referencing a ResourcePolicy ConfigMap with namespacedFilterPolicies
+// produces a validation error when old-style resource filters are also set on the spec.
+func TestPrepareBackupRequest_NamespacedFilterPoliciesIncompatibleWithOldFilters(t *testing.T) {
+ formatFlag := logging.FormatText
+ logger := logging.DefaultLogger(logrus.DebugLevel, formatFlag)
+
+ policyYAML := `version: v1
+namespacedFilterPolicies:
+- namespaces: ["production"]
+ resourceFilters:
+ - kinds: ["Deployment"]
+ names: ["api-server"]
+`
+ policyConfigMap := &corev1api.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "my-filter-policy",
+ Namespace: velerov1api.DefaultNamespace,
+ },
+ Data: map[string]string{"policy": policyYAML},
+ }
+
+ backup := defaultBackup().IncludedResources("deployments").Result()
+ backup.Spec.ResourcePolicy = &corev1api.TypedLocalObjectReference{
+ Kind: "configmap",
+ Name: "my-filter-policy",
+ }
+
+ fakeClient := velerotest.NewFakeControllerRuntimeClient(t, policyConfigMap)
+
+ apiServer := velerotest.NewAPIServer(t)
+ discoveryHelper, err := discovery.NewHelper(apiServer.DiscoveryClient, logger)
+ require.NoError(t, err)
+
+ c := &backupReconciler{
+ logger: logger,
+ discoveryHelper: discoveryHelper,
+ kbClient: fakeClient,
+ clock: &clock.RealClock{},
+ formatFlag: formatFlag,
+ }
+
+ res := c.prepareBackupRequest(ctx, backup, logger)
+
+ require.NotEmpty(t, res.Status.ValidationErrors)
+
+ hasTargetError := slices.ContainsFunc(res.Status.ValidationErrors, func(e string) bool {
+ return strings.Contains(e, "namespace-scoped or fine-grained global filter policies")
+ })
+
+ assert.True(t, hasTargetError, "expected validation error about namespacedFilterPolicies incompatibility with old-style filters, got: %v", res.Status.ValidationErrors)
+}
+
+// TestPrepareBackupRequest_ClusterScopedFilterPolicyIncompatibleWithOldFilters verifies
+// that a backup referencing a ResourcePolicy ConfigMap with clusterScopedFilterPolicy
+// produces a validation error when old-style resource filters are also set on the spec.
+func TestPrepareBackupRequest_ClusterScopedFilterPolicyIncompatibleWithOldFilters(t *testing.T) {
+ formatFlag := logging.FormatText
+ logger := logging.DefaultLogger(logrus.DebugLevel, formatFlag)
+
+ policyYAML := `version: v1
+clusterScopedFilterPolicy:
+ resourceFilters:
+ - kinds: ["ClusterRole"]
+ names: ["my-app-*"]
+`
+ policyConfigMap := &corev1api.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "my-cluster-filter-policy",
+ Namespace: velerov1api.DefaultNamespace,
+ },
+ Data: map[string]string{"policy": policyYAML},
+ }
+
+ backup := defaultBackup().IncludedResources("clusterroles").Result()
+ backup.Spec.ResourcePolicy = &corev1api.TypedLocalObjectReference{
+ Kind: "configmap",
+ Name: "my-cluster-filter-policy",
+ }
+
+ fakeClient := velerotest.NewFakeControllerRuntimeClient(t, policyConfigMap)
+
+ apiServer := velerotest.NewAPIServer(t)
+ discoveryHelper, err := discovery.NewHelper(apiServer.DiscoveryClient, logger)
+ require.NoError(t, err)
+
+ c := &backupReconciler{
+ logger: logger,
+ discoveryHelper: discoveryHelper,
+ kbClient: fakeClient,
+ clock: &clock.RealClock{},
+ formatFlag: formatFlag,
+ }
+
+ res := c.prepareBackupRequest(ctx, backup, logger)
+
+ require.NotEmpty(t, res.Status.ValidationErrors)
+
+ hasClusterError := slices.ContainsFunc(res.Status.ValidationErrors, func(e string) bool {
+ return strings.Contains(e, "namespace-scoped or fine-grained global filter policies")
+ })
+
+ assert.True(t, hasClusterError, "expected validation error about clusterScopedFilterPolicy incompatibility with old-style filters, got: %v", res.Status.ValidationErrors)
+}
+
+const (
+ namespacedFilterPolicyYAML = `version: v1
+namespacedFilterPolicies:
+- namespaces: ["production"]
+ resourceFilters:
+ - kinds: ["Deployment"]
+ names: ["api-server"]
+`
+ clusterScopedFilterPolicyYAML = `version: v1
+clusterScopedFilterPolicy:
+ resourceFilters:
+ - kinds: ["ClusterRole"]
+ names: ["my-app-*"]
+`
+ bothFilterPoliciesYAML = `version: v1
+namespacedFilterPolicies:
+- namespaces: ["production"]
+ resourceFilters:
+ - kinds: ["Deployment"]
+ names: ["api-server"]
+clusterScopedFilterPolicy:
+ resourceFilters:
+ - kinds: ["ClusterRole"]
+ names: ["my-app-*"]
+`
+)
+
+// TestPrepareBackupRequest_FilterPoliciesWithNewFilters verifies that backups referencing
+// a ResourcePolicy ConfigMap with namespacedFilterPolicies and/or clusterScopedFilterPolicy
+// succeed when old-style resource filters are not set on the spec.
+func TestPrepareBackupRequest_FilterPoliciesWithNewFilters(t *testing.T) {
+ tests := []struct {
+ name string
+ policyYAML string
+ policyConfigMapName string
+ backup *velerov1api.Backup
+ expectNamespacedPolicies int
+ expectClusterScopedPolicy bool
+ }{
+ {
+ name: "namespacedFilterPolicies only",
+ policyYAML: namespacedFilterPolicyYAML,
+ policyConfigMapName: "my-filter-policy",
+ backup: defaultBackup().StorageLocation("loc-1").Result(),
+ expectNamespacedPolicies: 1,
+ },
+ {
+ name: "clusterScopedFilterPolicy only",
+ policyYAML: clusterScopedFilterPolicyYAML,
+ policyConfigMapName: "my-cluster-filter-policy",
+ backup: defaultBackup().StorageLocation("loc-1").Result(),
+ expectClusterScopedPolicy: true,
+ },
+ {
+ name: "both filter policies",
+ policyYAML: bothFilterPoliciesYAML,
+ policyConfigMapName: "my-combined-filter-policy",
+ backup: defaultBackup().StorageLocation("loc-1").Result(),
+ expectNamespacedPolicies: 1,
+ expectClusterScopedPolicy: true,
+ },
+ {
+ name: "with new-style spec filters",
+ policyYAML: bothFilterPoliciesYAML,
+ policyConfigMapName: "my-combined-filter-policy",
+ backup: defaultBackup().
+ StorageLocation("loc-1").
+ IncludedNamespaceScopedResources("deployments").
+ IncludedClusterScopedResources("clusterroles").
+ Result(),
+ expectNamespacedPolicies: 1,
+ expectClusterScopedPolicy: true,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ formatFlag := logging.FormatText
+ logger := logging.DefaultLogger(logrus.DebugLevel, formatFlag)
+
+ policyConfigMap := &corev1api.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: test.policyConfigMapName,
+ Namespace: velerov1api.DefaultNamespace,
+ },
+ Data: map[string]string{"policy": test.policyYAML},
+ }
+
+ test.backup.Spec.ResourcePolicy = &corev1api.TypedLocalObjectReference{
+ Kind: "configmap",
+ Name: test.policyConfigMapName,
+ }
+
+ backupLocation := builder.ForBackupStorageLocation(velerov1api.DefaultNamespace, "loc-1").
+ Phase(velerov1api.BackupStorageLocationPhaseAvailable).Result()
+ fakeClient := velerotest.NewFakeControllerRuntimeClient(t, backupLocation, policyConfigMap)
+
+ apiServer := velerotest.NewAPIServer(t)
+ discoveryHelper, err := discovery.NewHelper(apiServer.DiscoveryClient, logger)
+ require.NoError(t, err)
+
+ c := &backupReconciler{
+ logger: logger,
+ discoveryHelper: discoveryHelper,
+ kbClient: fakeClient,
+ clock: &clock.RealClock{},
+ formatFlag: formatFlag,
+ }
+
+ res := c.prepareBackupRequest(ctx, test.backup, logger)
+ defer res.WorkerPool.Stop()
+
+ assert.Empty(t, res.Status.ValidationErrors)
+ hasIncompatibilityError := slices.ContainsFunc(res.Status.ValidationErrors, func(e string) bool {
+ return strings.Contains(e, "namespace-scoped or fine-grained global filter policies")
+ })
+ assert.False(t, hasIncompatibilityError)
+
+ require.NotNil(t, res.ResPolicies)
+ assert.Len(t, res.ResPolicies.GetNamespacedFilterPolicies(), test.expectNamespacedPolicies)
+ if test.expectClusterScopedPolicy {
+ assert.NotNil(t, res.ResPolicies.GetClusterScopedFilterPolicy())
+ } else {
+ assert.Nil(t, res.ResPolicies.GetClusterScopedFilterPolicy())
+ }
+ })
+ }
+}