From 0d6b5a4f9b3d3d7e4ba366dfdc5f6bc08027eff2 Mon Sep 17 00:00:00 2001 From: Adam Zhang Date: Wed, 8 Jul 2026 10:58:26 +0800 Subject: [PATCH] add fallback for unresolved kinds via peek-and-map When a user specifies a Custom Resource Kind in a restore filter policy (e.g., kinds: [MyCustomKind]), the discovery helper fails to resolve it if the CRD hasn't been restored yet. This adds a peek-and-map fallback: if a resource type in the backup tarball doesn't match the resolved filters, Velero peeks at the actual Kind of the first item in the tarball and matches it against the user's original policy strings. Signed-off-by: Adam Zhang --- .../fine-grained-restore-filters-design.md | 14 +++++ pkg/restore/restore.go | 46 ++++++++++++++++ pkg/restore/restore_test.go | 52 +++++++++++++++++++ 3 files changed, 112 insertions(+) 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,