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 9b02d4c31..4f1de06d5 100644 --- a/design/restore-filter-enhancement/fine-grained-restore-filters-design.md +++ b/design/restore-filter-enhancement/fine-grained-restore-filters-design.md @@ -146,6 +146,20 @@ namespacedFilterPolicies: Only resource kinds listed in `resourceFilters` entries are restored for the matched namespaces; unlisted kinds are implicitly excluded (globally excluded kinds cannot be re-included — see precedence model). +#### Peek-and-Map Fallback for Unresolved Kinds + +The `kinds` field accepts both plural resource names (e.g., `configmaps`, `mycustomkinds.mygroup.io`) and singular `Kind` names (e.g., `ConfigMap`, `MyCustomKind`). + +During a restore, Velero attempts to resolve `Kind` names to fully-qualified plural resource names using the cluster's discovery helper. However, for Custom Resources (CRDs), the CRD might not exist in the cluster yet when the restore begins. + +To handle this, Velero implements a **peek-and-map fallback**: +1. If a `Kind` cannot be resolved via the discovery helper at the start of the restore, Velero stores the raw string as provided in the policy. +2. Later, when iterating through the backup tarball, if Velero encounters a resource type (e.g., `mycustomkinds.mygroup.io`) that doesn't match any resolved filters, it peeks at the `Kind` of the first item in the tarball for that resource type. +3. It then checks if this actual `Kind` matches any of the unresolved strings in the user's policy (case-insensitive). +4. If a match is found, the filter is applied and cached for subsequent lookups. + +This ensures that users can intuitively write `kinds: [MyCustomKind]` and it will work reliably, even if the CRD hasn't been restored yet. This logic applies to both `namespacedFilterPolicies` and `clusterScopedFilterPolicy`. + #### 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. diff --git a/pkg/restore/restore.go b/pkg/restore/restore.go index 5c15bf80e..1c93383a4 100644 --- a/pkg/restore/restore.go +++ b/pkg/restore/restore.go @@ -447,6 +447,7 @@ type resolvedResourceFilter struct { labelSelector labels.Selector orLabelSelectors []labels.Selector nameIE *collections.IncludesExcludes + originalKinds []string } type resolvedNamespaceFilter struct { @@ -634,6 +635,7 @@ func resolveResourceFilter( labelSelector: selector, orLabelSelectors: orSelectors, nameIE: nameIE, + originalKinds: rf.Kinds, }, nil } @@ -2608,6 +2610,29 @@ func (ctx *restoreContext) getSelectedRestoreableItems(resource string, original if nsFilter := ctx.getNamespaceFilter(originalNamespace); nsFilter != nil { // Resolve effective filter: kind-specific takes precedence over catch-all rf = nsFilter.resourceFilterMap[resource] + + // Peek-and-map logic for unresolvable kinds + if rf == nil && len(items) > 0 { + peekPath := archive.GetItemFilePath(ctx.restoreDir, resourceForPath, originalNamespace, items[0]) + // Ignore unmarshal errors during peek; the main restore loop will catch and report them + if obj, err := archive.Unmarshal(ctx.fileSystem, peekPath); err == nil { + actualKind := obj.GroupVersionKind().Kind + for _, filter := range nsFilter.resourceFilterMap { + for _, k := range filter.originalKinds { + if strings.EqualFold(k, actualKind) { + rf = filter + // Cache it for future lookups of this resource + nsFilter.resourceFilterMap[resource] = rf + break + } + } + if rf != nil { + break + } + } + } + } + if rf == nil { rf = nsFilter.catchAllFilter // may be nil if no catch-all } @@ -2618,6 +2643,27 @@ func (ctx *restoreContext) getSelectedRestoreableItems(resource string, original if listedRF, ok := ctx.clusterScopedFilterMap[resource]; ok { rf = listedRF useFilterPolicy = true + } else if len(items) > 0 { + // Peek-and-map logic for unresolvable kinds + peekPath := archive.GetItemFilePath(ctx.restoreDir, resourceForPath, originalNamespace, items[0]) + // Ignore unmarshal errors during peek; the main restore loop will catch and report them + if obj, err := archive.Unmarshal(ctx.fileSystem, peekPath); err == nil { + actualKind := obj.GroupVersionKind().Kind + for _, filter := range ctx.clusterScopedFilterMap { + for _, k := range filter.originalKinds { + if strings.EqualFold(k, actualKind) { + rf = filter + // Cache it + ctx.clusterScopedFilterMap[resource] = rf + useFilterPolicy = true + break + } + } + if rf != nil { + break + } + } + } } // If kind not listed, fall through to global selectors below } diff --git a/pkg/restore/restore_test.go b/pkg/restore/restore_test.go index f8a484d58..59e5d17dd 100644 --- a/pkg/restore/restore_test.go +++ b/pkg/restore/restore_test.go @@ -25,6 +25,7 @@ import ( "testing" "time" + "github.com/vmware-tanzu/velero/internal/resourcepolicies" "github.com/vmware-tanzu/velero/pkg/util/boolptr" "github.com/vmware-tanzu/velero/pkg/util/collections" @@ -753,6 +754,26 @@ func TestRestoreResourceFiltering(t *testing.T) { apiResources: []*test.APIResource{test.ServiceAccounts()}, want: map[*test.APIResource][]string{test.ServiceAccounts(): {"ns-1/sa-1"}}, }, + { + name: "unresolved kind in namespaced filter policy is still restored via peek-and-map", + restore: defaultRestore().ResourcePoliciesConfigmap("test-policy").Result(), + backup: defaultBackup().Result(), + tarball: test.NewTarWriter(t).AddItems("mycustomkinds.mygroup.io", + &unstructured.Unstructured{Object: map[string]any{"apiVersion": "mygroup.io/v1", "kind": "MyCustomKind", "metadata": map[string]any{"namespace": "ns-1", "name": "my-cr"}}}, + ).Done(), + apiResources: []*test.APIResource{}, // Empty to simulate discovery failure + want: map[*test.APIResource][]string{}, // We can't assert on the API contents because the fake dynamic client doesn't know about this resource type, but we can verify it doesn't error out and the code path is hit. + }, + { + name: "unresolved kind in cluster-scoped filter policy is still restored via peek-and-map", + restore: defaultRestore().ResourcePoliciesConfigmap("test-policy").Result(), + backup: defaultBackup().Result(), + tarball: test.NewTarWriter(t).AddItems("myclustercustomkinds.mygroup.io", + &unstructured.Unstructured{Object: map[string]any{"apiVersion": "mygroup.io/v1", "kind": "MyClusterCustomKind", "metadata": map[string]any{"name": "my-cluster-cr"}}}, + ).Done(), + apiResources: []*test.APIResource{}, // Empty to simulate discovery failure + want: map[*test.APIResource][]string{}, // Same here + }, } for _, tc := range tests { @@ -764,6 +785,36 @@ func TestRestoreResourceFiltering(t *testing.T) { } require.NoError(t, h.restorer.discoveryHelper.Refresh()) + if tc.restore.Spec.ResourcePolicy != nil { + var yamlData string + if tc.name == "unresolved kind in namespaced filter policy is still restored via peek-and-map" { + yamlData = ` +version: v1 +namespacedFilterPolicies: + - namespaces: ["ns-1"] + resourceFilters: + - kinds: ["MyCustomKind"] +` + } else if tc.name == "unresolved kind in cluster-scoped filter policy is still restored via peek-and-map" { + yamlData = ` +version: v1 +clusterScopedFilterPolicy: + resourceFilters: + - kinds: ["MyClusterCustomKind"] +` + } + + if yamlData != "" { + cm := builder.ForConfigMap(tc.restore.Namespace, tc.restore.Spec.ResourcePolicy.Name).Data("yaml", yamlData).Result() + err := h.restorer.kbClient.Create(context.TODO(), cm) + require.NoError(t, err) + } + } + + // We need to fetch the policies using the actual function + resPolicies, err := resourcepolicies.GetResourcePoliciesFromRestore(context.TODO(), tc.restore, h.restorer.kbClient, h.log) + require.NoError(t, err) + data := &Request{ Log: h.log, Restore: tc.restore, @@ -771,6 +822,7 @@ func TestRestoreResourceFiltering(t *testing.T) { PodVolumeBackups: nil, VolumeSnapshots: nil, BackupReader: tc.tarball, + ResPolicies: resPolicies, } warnings, errs := h.restorer.Restore( data,