diff --git a/changelogs/unreleased/10033-adam-jian-zhang b/changelogs/unreleased/10033-adam-jian-zhang new file mode 100644 index 000000000..62a676ab9 --- /dev/null +++ b/changelogs/unreleased/10033-adam-jian-zhang @@ -0,0 +1 @@ +Fix issue #10032, prioritize exact namespace match in restore diff --git a/design/restore-filter-enhancement/fine-grained-restore-filters-design.md b/design/restore-filter-enhancement/fine-grained-restore-filters-design.md index 0e4107171..913c056c0 100644 --- a/design/restore-filter-enhancement/fine-grained-restore-filters-design.md +++ b/design/restore-filter-enhancement/fine-grained-restore-filters-design.md @@ -307,6 +307,9 @@ The `getNamespaceFilter()` method on `restoreContext` takes the original namespa **Plugin Additional Items (Restore-Side):** Like the backup side — which is permissive at Stage 2 to allow CSI plugin-injected resources through — the restore side is permissive for AdditionalItems in `restoreItem()`. If a restore plugin requests an additional item, it is allowed to bypass the fine-grained `namespacedFilterPolicies` and `clusterScopedFilterPolicy` kind, name, and label selector checks. This allows plugins to successfully restore dependencies (like a PV needed by a PVC, or a specific Secret) without the user having to explicitly authorize every single dependent resource type in their configuration. Note that these additional items must still pass global resource/namespace exclusions. +**Exact Namespace Match Priority:** +If a namespace matches both an exact name pattern and a glob pattern across different `namespacedFilterPolicies` entries, the exact match always takes precedence, regardless of list order. This aligns with the backup pipeline behavior and ensures specific overrides are always honored. + **Multiple Glob Patterns Matching Same Namespace (Incorrect Order):** ```yaml namespacedFilterPolicies: @@ -665,7 +668,7 @@ data: ### Restore with Glob Namespace Patterns -Apply the same filter to all namespaces matching a pattern. **Critical: Order patterns from most specific to least specific:** +Apply the same filter to all namespaces matching a pattern. **Note on Precedence:** Exact namespace matches always take precedence regardless of where they are listed. However, if multiple glob patterns could match a namespace, they are evaluated in the order they appear. Always list specific globs before broad globs. ```yaml apiVersion: v1 @@ -677,19 +680,21 @@ data: policy: | version: v1 namespacedFilterPolicies: - # More specific patterns first + # Globs must be ordered specific-to-broad - namespaces: - - "team-frontend-prod" # Most specific (exact match) - resourceFilters: - - kinds: [Deployment, Service, ConfigMap, Secret, PersistentVolumeClaim] - - namespaces: - - "team-frontend-*" # Less specific (pattern match) + - "team-frontend-*" # specific pattern match resourceFilters: - kinds: [Deployment, Service, ConfigMap] - namespaces: - - "team-*" # Least specific (broad pattern) + - "team-*" # broad pattern resourceFilters: - kinds: [Deployment, Service] + + # Exact matches always win, even if placed at the bottom + - namespaces: + - "team-frontend-prod" # exact match + resourceFilters: + - kinds: [Deployment, Service, ConfigMap, Secret, PersistentVolumeClaim] ``` **Pattern Matching Results:** diff --git a/pkg/restore/restore.go b/pkg/restore/restore.go index a71fc4b23..8205accb9 100644 --- a/pkg/restore/restore.go +++ b/pkg/restore/restore.go @@ -478,7 +478,15 @@ func (ctx *restoreContext) getNamespaceFilter(namespace string) *resolvedNamespa return filter } - // 2. Walk patterns in definition order (first-match semantics) + // 2. Check for exact match first (O(1) map lookup) + // This ensures exact namespace matches take precedence over globs, + // regardless of where they are listed in the configuration. + if filter, ok := ctx.namespacedFilterMap[namespace]; ok { + ctx.namespaceFilterCache[namespace] = filter + return filter + } + + // 3. Walk patterns in definition order using pre-compiled globs // Note: namespaceFilterCache is mutated below without synchronization. This is safe // today because resource collection runs sequentially. If the restore loop is // parallelized in the future, these map writes will need a lock to prevent data races. @@ -489,14 +497,10 @@ func (ctx *restoreContext) getNamespaceFilter(namespace string) *resolvedNamespa ctx.namespaceFilterCache[namespace] = filter return filter } - } else if p.pattern == namespace { - filter := ctx.namespacedFilterMap[p.pattern] - ctx.namespaceFilterCache[namespace] = filter - return filter } } - // 3. Cache the miss so we don't re-evaluate failed matches + // 4. Cache the miss so we don't re-evaluate failed matches ctx.namespaceFilterCache[namespace] = nil return nil } diff --git a/pkg/restore/restore_policies_test.go b/pkg/restore/restore_policies_test.go index 42b8fb11f..a027f66aa 100644 --- a/pkg/restore/restore_policies_test.go +++ b/pkg/restore/restore_policies_test.go @@ -62,7 +62,7 @@ namespacedFilterPolicies: }, }, { - name: "namespaced filter policy with glob namespace match and first-match semantics", + name: "namespaced filter policy with exact match priority over glob (glob listed first)", restore: defaultRestore().Result(), backup: defaultBackup().Result(), policyYAML: `version: v1 @@ -94,7 +94,43 @@ namespacedFilterPolicies: test.Pods(), }, want: map[*test.APIResource][]string{ - test.Pods(): {"ns-1/pod-1", "ns-2/pod-1"}, + test.Pods(): {"ns-1/pod-2", "ns-2/pod-1"}, + }, + }, + { + name: "namespaced filter policy with exact match priority over glob (exact listed first)", + restore: defaultRestore().Result(), + backup: defaultBackup().Result(), + policyYAML: `version: v1 +namespacedFilterPolicies: + - namespaces: + - ns-1 + resourceFilters: + - kinds: + - pods + names: + - pod-2 + - namespaces: + - ns-* + resourceFilters: + - kinds: + - pods + names: + - pod-1 +`, + tarball: test.NewTarWriter(t). + AddItems("pods", + builder.ForPod("ns-1", "pod-1").Result(), + builder.ForPod("ns-1", "pod-2").Result(), + builder.ForPod("ns-2", "pod-1").Result(), + builder.ForPod("ns-2", "pod-2").Result(), + ). + Done(), + apiResources: []*test.APIResource{ + test.Pods(), + }, + want: map[*test.APIResource][]string{ + test.Pods(): {"ns-1/pod-2", "ns-2/pod-1"}, }, }, {