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 <adam.zhang@broadcom.com>
This commit is contained in:
Adam Zhang
2026-07-08 10:58:26 +08:00
parent b41e5df294
commit 0d6b5a4f9b
3 changed files with 112 additions and 0 deletions
@@ -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.
+46
View File
@@ -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
}
+52
View File
@@ -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,