address review comments

- normalized the input to lower case for consistency
- added validations for kind collision
- add flag for unresolved kinds, and defer skip decision base on that
- move peek-and-map test cases to restore_policies_test.go

Signed-off-by: Adam Zhang <adam.zhang@broadcom.com>
This commit is contained in:
Adam Zhang
2026-07-09 11:50:20 +08:00
parent 02b6e16088
commit f3beea83da
6 changed files with 156 additions and 88 deletions
+10 -11
View File
@@ -18,7 +18,6 @@ package controller
import (
"bytes"
"context"
"io"
"testing"
"time"
@@ -786,7 +785,7 @@ func TestValidateAndCompleteWhenScheduleNameSpecified(t *testing.T) {
Phase(velerov1api.BackupPhaseCompleted).
Result()))
r.validateAndComplete(context.Background(), restore)
r.validateAndComplete(t.Context(), restore)
assert.Contains(t, restore.Status.ValidationErrors, "No backups found for schedule")
assert.Empty(t, restore.Spec.BackupName)
@@ -802,7 +801,7 @@ func TestValidateAndCompleteWhenScheduleNameSpecified(t *testing.T) {
Result(),
))
r.validateAndComplete(context.Background(), restore)
r.validateAndComplete(t.Context(), restore)
assert.Contains(t, restore.Status.ValidationErrors, "No completed backups found for schedule")
assert.Empty(t, restore.Spec.BackupName)
@@ -833,7 +832,7 @@ func TestValidateAndCompleteWhenScheduleNameSpecified(t *testing.T) {
ScheduleName: "schedule-1",
},
}
r.validateAndComplete(context.Background(), restore)
r.validateAndComplete(t.Context(), restore)
assert.Nil(t, restore.Status.ValidationErrors)
assert.Equal(t, "foo", restore.Spec.BackupName)
}
@@ -893,7 +892,7 @@ func TestValidateAndCompleteWithResourcePolicySpecified(t *testing.T) {
Result(),
))
r.validateAndComplete(context.Background(), restore)
r.validateAndComplete(t.Context(), restore)
assert.Contains(t, restore.Status.ValidationErrors[0], "fail to get ResourcePolicies velero/test-configmap ConfigMap")
restore1 := &velerov1api.Restore{
@@ -926,7 +925,7 @@ clusterScopedFilterPolicy:
}
require.NoError(t, r.kbClient.Create(t.Context(), cm1))
r.validateAndComplete(context.Background(), restore1)
r.validateAndComplete(t.Context(), restore1)
assert.Nil(t, restore1.Status.ValidationErrors)
restore2 := &velerov1api.Restore{
@@ -963,7 +962,7 @@ volumePolicies:
}
require.NoError(t, r.kbClient.Create(t.Context(), cm2))
r.validateAndComplete(context.Background(), restore2)
r.validateAndComplete(t.Context(), restore2)
assert.Contains(t, restore2.Status.ValidationErrors[0], "fail to validate ResourcePolicies in ConfigMap velero/test-configmap-invalid")
}
@@ -1022,7 +1021,7 @@ func TestValidateAndCompleteWithResourceModifierSpecified(t *testing.T) {
Result(),
))
r.validateAndComplete(context.Background(), restore)
r.validateAndComplete(t.Context(), restore)
assert.Contains(t, restore.Status.ValidationErrors[0], "failed to get resource modifiers configmap")
restore1 := &velerov1api.Restore{
@@ -1050,7 +1049,7 @@ func TestValidateAndCompleteWithResourceModifierSpecified(t *testing.T) {
}
require.NoError(t, r.kbClient.Create(t.Context(), cm1))
r.validateAndComplete(context.Background(), restore1)
r.validateAndComplete(t.Context(), restore1)
assert.Nil(t, restore1.Status.ValidationErrors)
restore2 := &velerov1api.Restore{
@@ -1079,7 +1078,7 @@ func TestValidateAndCompleteWithResourceModifierSpecified(t *testing.T) {
}
require.NoError(t, r.kbClient.Create(t.Context(), invalidVersionCm))
r.validateAndComplete(context.Background(), restore2)
r.validateAndComplete(t.Context(), restore2)
assert.Contains(t, restore2.Status.ValidationErrors[0], "Error in parsing resource modifiers provided in configmap")
restore3 := &velerov1api.Restore{
@@ -1107,7 +1106,7 @@ func TestValidateAndCompleteWithResourceModifierSpecified(t *testing.T) {
}
require.NoError(t, r.kbClient.Create(t.Context(), invalidOperatorCm))
r.validateAndComplete(context.Background(), restore3)
r.validateAndComplete(t.Context(), restore3)
assert.Contains(t, restore3.Status.ValidationErrors[0], "Validation error in resource modifiers provided in configmap")
}
+58 -26
View File
@@ -456,6 +456,9 @@ type resolvedNamespaceFilter struct {
// catchAllFilter holds the resolved filter for a catch-all entry (empty kinds or ["*"]).
// nil when no catch-all entry is defined.
catchAllFilter *resolvedResourceFilter
// hasUnresolvedKinds is true if any kind in the policy failed discovery.
// This is used to bypass the fast-path skip so the peek-and-map fallback can run.
hasUnresolvedKinds bool
}
// namespacedFilterPattern pairs a namespace pattern string with its pre-compiled
@@ -514,17 +517,24 @@ func resolveRestoreClusterScopedFilterPolicy(
if err != nil {
return nil, err
}
for _, kind := range rf.Kinds {
gr, resource, err := helper.ResourceFor(schema.GroupVersionResource{Resource: kind})
for _, kind := range resolved.originalKinds {
gr, resource, err := helper.ResourceFor(schema.ParseGroupResource(kind).WithVersion(""))
key := kind
if err != nil {
log.WithField("kind", kind).Warnf("Cannot resolve kind via discovery, using as-is")
result[kind] = resolved
continue
} else {
if resource.Namespaced {
log.Warnf("kind %q in clusterScopedFilterPolicy is a namespace-scoped resource; it will never match in a cluster-scoped filter — did you mean namespacedFilterPolicies?", kind)
}
key = gr.GroupResource().String()
}
if resource.Namespaced {
log.Warnf("kind %q in clusterScopedFilterPolicy is a namespace-scoped resource; it will never match in a cluster-scoped filter — did you mean namespacedFilterPolicies?", kind)
if _, exists := result[key]; exists {
return nil, fmt.Errorf("ambiguous policy: duplicate kind %q detected", key)
}
result[gr.GroupResource().String()] = resolved
result[key] = resolved
}
}
return result, nil
@@ -548,6 +558,7 @@ func resolveRestoreNamespacedFilterPolicies(
for _, policy := range policies {
rfMap := make(map[string]*resolvedResourceFilter)
var catchAll *resolvedResourceFilter
hasUnresolvedKinds := false
for _, rf := range policy.ResourceFilters {
resolved, err := resolveResourceFilter(rf)
@@ -560,35 +571,42 @@ func resolveRestoreNamespacedFilterPolicies(
continue
}
for _, kind := range rf.Kinds {
for _, kind := range resolved.originalKinds {
gr, resource, err := helper.ResourceFor(
schema.GroupVersionResource{Resource: kind},
schema.ParseGroupResource(kind).WithVersion(""),
)
key := kind
if err != nil {
log.WithField("kind", kind).Warnf(
"Cannot resolve kind via discovery, using as-is")
rfMap[kind] = resolved
continue
hasUnresolvedKinds = true
} else {
if !resource.Namespaced {
log.Warnf("kind %q in namespacedFilterPolicies is a cluster-scoped resource; it will never match in a namespace-scoped filter — did you mean clusterScopedFilterPolicy?", kind)
}
if globalExcludes[kind] || globalExcludes[gr.GroupResource().String()] {
log.WithFields(logrus.Fields{
"kind": kind,
"namespacePattern": strings.Join(policy.Namespaces, ","),
}).Warn("namespacedFilterPolicies entry lists a kind that is globally excluded by RestoreSpec.ExcludedResources; the per-namespace filter entry has no effect")
}
key = gr.GroupResource().String()
}
if !resource.Namespaced {
log.Warnf("kind %q in namespacedFilterPolicies is a cluster-scoped resource; it will never match in a namespace-scoped filter — did you mean clusterScopedFilterPolicy?", kind)
if _, exists := rfMap[key]; exists {
return nil, nil, fmt.Errorf("ambiguous policy: duplicate kind %q detected", key)
}
if globalExcludes[kind] || globalExcludes[gr.GroupResource().String()] {
log.WithFields(logrus.Fields{
"kind": kind,
"namespacePattern": strings.Join(policy.Namespaces, ","),
}).Warn("namespacedFilterPolicies entry lists a kind that is globally excluded by RestoreSpec.ExcludedResources; the per-namespace filter entry has no effect")
}
rfMap[gr.GroupResource().String()] = resolved
rfMap[key] = resolved
}
}
nsFilter := &resolvedNamespaceFilter{
resourceFilterMap: rfMap,
catchAllFilter: catchAll,
resourceFilterMap: rfMap,
catchAllFilter: catchAll,
hasUnresolvedKinds: hasUnresolvedKinds,
}
for _, nsPattern := range policy.Namespaces {
result[nsPattern] = nsFilter
@@ -634,11 +652,17 @@ func resolveResourceFilter(
if len(rf.Names) > 0 || len(rf.ExcludedNames) > 0 {
nameIE = collections.NewIncludesExcludes().Includes(rf.Names...).Excludes(rf.ExcludedNames...)
}
normalizedKinds := make([]string, len(rf.Kinds))
for i, k := range rf.Kinds {
normalizedKinds[i] = strings.ToLower(k)
}
return &resolvedResourceFilter{
labelSelector: selector,
orLabelSelectors: orSelectors,
nameIE: nameIE,
originalKinds: rf.Kinds,
originalKinds: normalizedKinds,
}, nil
}
@@ -2543,7 +2567,7 @@ func (ctx *restoreContext) getOrderedResourceCollection(
if namespace != "" && !ctx.resourceMustHave.Has(groupResource.String()) {
if nsFilter := ctx.getNamespaceFilter(namespace); nsFilter != nil {
_, kindListed := nsFilter.resourceFilterMap[groupResource.String()]
if !kindListed && nsFilter.catchAllFilter == nil {
if !kindListed && nsFilter.catchAllFilter == nil && !nsFilter.hasUnresolvedKinds {
ctx.log.Infof("Skipping resource %s in namespace %s: not in resourceFilters",
resource, namespace)
continue
@@ -2643,6 +2667,11 @@ func (ctx *restoreContext) getSelectedRestoreableItems(resource string, original
rf = nsFilter.catchAllFilter // may be nil if no catch-all
}
useFilterPolicy = true
if rf == nil {
ctx.log.Infof("Skipping resource %s in namespace %s: not in resourceFilters", resource, originalNamespace)
return restorable, warnings, errs
}
}
} else if ctx.clusterScopedFilterMap != nil {
// Cluster-scoped path: only applies if kind is listed (refinement overlay)
@@ -2650,7 +2679,10 @@ func (ctx *restoreContext) getSelectedRestoreableItems(resource string, original
rf = listedRF
useFilterPolicy = true
} else if len(items) > 0 {
// Peek-and-map logic for unresolvable kinds
// Peek-and-map logic for unresolvable kinds.
// Note: Unlike the namespaced path, this fallback is always reachable
// because the main restore loop does not have a fast-path skip for
// unlisted cluster-scoped resources.
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 {
+81 -2
View File
@@ -1,7 +1,6 @@
package restore
import (
"context"
"io"
"testing"
@@ -9,6 +8,7 @@ import (
"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/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
@@ -19,6 +19,9 @@ import (
)
func TestRestoreResourcePoliciesFiltering(t *testing.T) {
customKindRes := &test.APIResource{Group: "mygroup.io", Version: "v1", Name: "mycustomkinds", Kind: "MyCustomKind", Namespaced: true}
clusterCustomKindRes := &test.APIResource{Group: "mygroup.io", Version: "v1", Name: "myclustercustomkinds", Kind: "MyClusterCustomKind", Namespaced: false}
tests := []struct {
name string
restore *velerov1api.Restore
@@ -150,6 +153,45 @@ namespacedFilterPolicies:
test.Deployments(): {"ns-1/deploy-1"},
},
},
{
name: "unresolved kind in namespaced filter policy is still restored via peek-and-map",
restore: defaultRestore().Result(),
backup: defaultBackup().Result(),
policyYAML: `version: v1
namespacedFilterPolicies:
- namespaces: ["ns-1"]
resourceFilters:
- kinds: ["MyCustomKind"]
`,
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{
customKindRes,
},
want: map[*test.APIResource][]string{
customKindRes: {"ns-1/my-cr"},
},
},
{
name: "unresolved kind in cluster-scoped filter policy is still restored via peek-and-map",
restore: defaultRestore().Result(),
backup: defaultBackup().Result(),
policyYAML: `version: v1
clusterScopedFilterPolicy:
resourceFilters:
- kinds: ["MyClusterCustomKind"]
`,
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{
clusterCustomKindRes,
},
want: map[*test.APIResource][]string{
clusterCustomKindRes: {"/my-cluster-cr"},
},
},
}
for _, tc := range tests {
@@ -180,7 +222,7 @@ namespacedFilterPolicies:
Name: "test-policies",
}
var err error
resPolicies, err = resourcepolicies.GetResourcePoliciesFromRestore(context.Background(), restore, client, logrus.New())
resPolicies, err = resourcepolicies.GetResourcePoliciesFromRestore(t.Context(), restore, client, logrus.New())
require.NoError(t, err)
}
@@ -204,3 +246,40 @@ namespacedFilterPolicies:
})
}
}
func TestResolveRestoreNamespacedFilterPolicies_Validation(t *testing.T) {
log := logrus.New()
helper := test.NewFakeDiscoveryHelper(true, nil)
policies := []resourcepolicies.NamespacedFilterPolicy{
{
Namespaces: []string{"ns-1"},
ResourceFilters: []resourcepolicies.ResourceFilter{
{
Kinds: []string{"MyKind", "mykind"},
},
},
},
}
_, _, err := resolveRestoreNamespacedFilterPolicies(policies, nil, helper, log)
require.Error(t, err)
require.Contains(t, err.Error(), "ambiguous policy: duplicate kind")
}
func TestResolveRestoreClusterScopedFilterPolicy_Validation(t *testing.T) {
log := logrus.New()
helper := test.NewFakeDiscoveryHelper(true, nil)
policy := &resourcepolicies.ClusterScopedFilterPolicy{
ResourceFilters: []resourcepolicies.ResourceFilter{
{
Kinds: []string{"MyKind", "mykind"},
},
},
}
_, err := resolveRestoreClusterScopedFilterPolicy(policy, helper, log)
require.Error(t, err)
require.Contains(t, err.Error(), "ambiguous policy: duplicate kind")
}
+1 -47
View File
@@ -754,26 +754,6 @@ 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 {
@@ -785,34 +765,8 @@ 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)
resPolicies, err := resourcepolicies.GetResourcePoliciesFromRestore(t.Context(), tc.restore, h.restorer.kbClient, h.log)
require.NoError(t, err)
data := &Request{
+2
View File
@@ -56,6 +56,8 @@ func NewAPIServer(t *testing.T) *APIServer {
{Group: "extensions", Version: "v1", Resource: "deployments"}: "ExtDeploymentsList",
{Group: "velero.io", Version: "v1", Resource: "deployments"}: "VeleroDeploymentsList",
{Group: "velero.io", Version: "v2alpha1", Resource: "datauploads"}: "DataUploadsList",
{Group: "mygroup.io", Version: "v1", Resource: "mycustomkinds"}: "MyCustomKindList",
{Group: "mygroup.io", Version: "v1", Resource: "myclustercustomkinds"}: "MyClusterCustomKindList",
})
discoveryClient = &DiscoveryClient{FakeDiscovery: kubeClient.Discovery().(*discoveryfake.FakeDiscovery)}
)