/* Copyright 2017, 2019, 2020 the Velero contributors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package backup import ( "testing" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "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/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" "github.com/vmware-tanzu/velero/pkg/builder" "github.com/vmware-tanzu/velero/pkg/kuberesource" "github.com/vmware-tanzu/velero/pkg/test" "github.com/vmware-tanzu/velero/pkg/util/collections" ) func TestSortCoreGroup(t *testing.T) { group := &metav1.APIResourceList{ GroupVersion: "v1", APIResources: []metav1.APIResource{ {Name: "persistentvolumes"}, {Name: "configmaps"}, {Name: "antelopes"}, {Name: "persistentvolumeclaims"}, {Name: "pods"}, }, } sortCoreGroup(group) expected := []string{ "pods", "persistentvolumeclaims", "persistentvolumes", "configmaps", "antelopes", } for i, r := range group.APIResources { assert.Equal(t, expected[i], r.Name) } } func TestSortOrderedResource(t *testing.T) { log := logrus.StandardLogger() podResources := []*kubernetesResource{ {namespace: "ns1", name: "pod3"}, {namespace: "ns1", name: "pod1"}, {namespace: "ns1", name: "pod2"}, } order := []string{"ns1/pod2", "ns1/pod1"} expectedResources := []*kubernetesResource{ {namespace: "ns1", name: "pod2", orderedResource: true}, {namespace: "ns1", name: "pod1", orderedResource: true}, {namespace: "ns1", name: "pod3"}, } sortedResources := sortResourcesByOrder(log, podResources, order) assert.Equal(t, expectedResources, sortedResources) // Test cluster resources pvResources := []*kubernetesResource{ {name: "pv1"}, {name: "pv2"}, {name: "pv3"}, } pvOrder := []string{"pv5", "pv2", "pv1"} expectedPvResources := []*kubernetesResource{ {name: "pv2", orderedResource: true}, {name: "pv1", orderedResource: true}, {name: "pv3"}, } sortedPvResources := sortResourcesByOrder(log, pvResources, pvOrder) assert.Equal(t, expectedPvResources, sortedPvResources) } func TestFilterNamespaces(t *testing.T) { tests := []struct { name string resources []*kubernetesResource needToTrack string expectedResources []*kubernetesResource }{ { name: "Namespace include by the filter but not in namespacesContainResource", resources: []*kubernetesResource{ { groupResource: kuberesource.Namespaces, preferredGVR: kuberesource.Namespaces.WithVersion("v1"), name: "ns1", }, { groupResource: kuberesource.Namespaces, preferredGVR: kuberesource.Namespaces.WithVersion("v1"), name: "ns2", }, { groupResource: kuberesource.Pods, preferredGVR: kuberesource.Namespaces.WithVersion("v1"), name: "pod1", }, }, needToTrack: "ns1", expectedResources: []*kubernetesResource{ { groupResource: kuberesource.Namespaces, preferredGVR: kuberesource.Namespaces.WithVersion("v1"), name: "ns1", }, { groupResource: kuberesource.Pods, preferredGVR: kuberesource.Namespaces.WithVersion("v1"), name: "pod1", }, }, }, } for _, tc := range tests { t.Run(tc.name, func(*testing.T) { r := itemCollector{ backupRequest: &Request{}, } if tc.needToTrack != "" { r.nsTracker.track(tc.needToTrack) } require.Equal(t, tc.expectedResources, r.nsTracker.filterNamespaces(tc.resources)) }) } } func TestItemCollectorBackupNamespaces(t *testing.T) { tests := []struct { name string ie *collections.NamespaceIncludesExcludes namespaces []*corev1api.Namespace backup *velerov1api.Backup expectedTrackedNS []string converter runtime.UnstructuredConverter }{ { name: "ns filter by namespace IE filter", backup: builder.ForBackup("velero", "backup").Result(), ie: collections.NewNamespaceIncludesExcludes().Includes("ns1"), namespaces: []*corev1api.Namespace{ builder.ForNamespace("ns1").Phase(corev1api.NamespaceActive).Result(), builder.ForNamespace("ns2").Phase(corev1api.NamespaceActive).Result(), }, expectedTrackedNS: []string{"ns1"}, }, { name: "ns filter by backup labelSelector", backup: builder.ForBackup("velero", "backup").LabelSelector(&metav1.LabelSelector{ MatchLabels: map[string]string{"name": "ns1"}, }).Result(), ie: collections.NewNamespaceIncludesExcludes().Includes("*"), namespaces: []*corev1api.Namespace{ builder.ForNamespace("ns1").ObjectMeta(builder.WithLabels("name", "ns1")).Phase(corev1api.NamespaceActive).Result(), builder.ForNamespace("ns2").Phase(corev1api.NamespaceActive).Result(), }, expectedTrackedNS: []string{"ns1"}, }, { name: "ns filter by backup orLabelSelector", backup: builder.ForBackup("velero", "backup").OrLabelSelector([]*metav1.LabelSelector{ {MatchLabels: map[string]string{"name": "ns1"}}, }).Result(), ie: collections.NewNamespaceIncludesExcludes().Includes("*"), namespaces: []*corev1api.Namespace{ builder.ForNamespace("ns1").ObjectMeta(builder.WithLabels("name", "ns1")).Phase(corev1api.NamespaceActive).Result(), builder.ForNamespace("ns2").Phase(corev1api.NamespaceActive).Result(), }, expectedTrackedNS: []string{"ns1"}, }, { name: "ns not included by IE filter, but included by labelSelector", backup: builder.ForBackup("velero", "backup").LabelSelector(&metav1.LabelSelector{ MatchLabels: map[string]string{"name": "ns1"}, }).Result(), ie: collections.NewNamespaceIncludesExcludes().Excludes("ns1"), namespaces: []*corev1api.Namespace{ builder.ForNamespace("ns1").ObjectMeta(builder.WithLabels("name", "ns1")).Phase(corev1api.NamespaceActive).Result(), builder.ForNamespace("ns2").Phase(corev1api.NamespaceActive).Result(), }, expectedTrackedNS: []string{"ns1"}, }, { name: "ns not included by IE filter, but included by orLabelSelector", backup: builder.ForBackup("velero", "backup").OrLabelSelector([]*metav1.LabelSelector{ {MatchLabels: map[string]string{"name": "ns1"}}, }).Result(), ie: collections.NewNamespaceIncludesExcludes().Excludes("ns1", "ns2"), namespaces: []*corev1api.Namespace{ builder.ForNamespace("ns1").ObjectMeta(builder.WithLabels("name", "ns1")).Phase(corev1api.NamespaceActive).Result(), builder.ForNamespace("ns2").Phase(corev1api.NamespaceActive).Result(), builder.ForNamespace("ns3").Phase(corev1api.NamespaceActive).Result(), }, expectedTrackedNS: []string{"ns1", "ns3"}, }, { name: "No ns filters", backup: builder.ForBackup("velero", "backup").Result(), ie: collections.NewNamespaceIncludesExcludes().Includes("*"), namespaces: []*corev1api.Namespace{ builder.ForNamespace("ns1").ObjectMeta(builder.WithLabels("name", "ns1")).Phase(corev1api.NamespaceActive).Result(), builder.ForNamespace("ns2").Phase(corev1api.NamespaceActive).Result(), }, expectedTrackedNS: []string{"ns1", "ns2"}, }, { name: "ns specified by the IncludeNamespaces cannot be found", backup: builder.ForBackup("velero", "backup").IncludedNamespaces("ns1", "invalid", "*").Result(), ie: collections.NewNamespaceIncludesExcludes().Includes("ns1", "invalid", "*"), namespaces: []*corev1api.Namespace{ builder.ForNamespace("ns1").ObjectMeta(builder.WithLabels("name", "ns1")).Phase(corev1api.NamespaceActive).Result(), builder.ForNamespace("ns2").Phase(corev1api.NamespaceActive).Result(), builder.ForNamespace("ns3").Phase(corev1api.NamespaceActive).Result(), }, expectedTrackedNS: []string{"ns1"}, }, { name: "terminating ns should not tracked", backup: builder.ForBackup("velero", "backup").Result(), ie: collections.NewNamespaceIncludesExcludes().Includes("ns1", "ns2"), namespaces: []*corev1api.Namespace{ builder.ForNamespace("ns1").Phase(corev1api.NamespaceTerminating).Result(), builder.ForNamespace("ns2").Phase(corev1api.NamespaceActive).Result(), }, expectedTrackedNS: []string{"ns2"}, }, } for _, tc := range tests { t.Run(tc.name, func(*testing.T) { tempDir := t.TempDir() var unstructuredNSList unstructured.UnstructuredList for _, ns := range tc.namespaces { unstructuredNS, err := runtime.DefaultUnstructuredConverter.ToUnstructured(ns) require.NoError(t, err) unstructuredNSList.Items = append(unstructuredNSList.Items, unstructured.Unstructured{Object: unstructuredNS}) } dc := &test.FakeDynamicClient{} dc.On("List", mock.Anything).Return(&unstructuredNSList, nil) factory := &test.FakeDynamicFactory{} factory.On( "ClientForGroupVersionResource", mock.Anything, mock.Anything, mock.Anything, ).Return(dc, nil) r := itemCollector{ backupRequest: &Request{ Backup: tc.backup, NamespaceIncludesExcludes: tc.ie, }, dynamicFactory: factory, discoveryHelper: test.NewFakeDiscoveryHelper(true, nil), dir: tempDir, } if tc.converter == nil { tc.converter = runtime.DefaultUnstructuredConverter } r.collectNamespaces( metav1.APIResource{ Name: "Namespace", Kind: "Namespace", Namespaced: false, }, kuberesource.Namespaces.WithVersion("").GroupVersion(), kuberesource.Namespaces, kuberesource.Namespaces.WithVersion(""), logrus.StandardLogger(), ) for _, ns := range tc.expectedTrackedNS { require.True(t, r.nsTracker.isTracked(ns)) } }) } } // TestNamespacedFilterMap_GlobalExclusionPrecedence verifies the precedence rule: // ResourceIncludesExcludes (set by includeExcludePolicy) is checked before the // NamespacedFilterMap. This is enforced at both Stage 1 (item_collector.go line ~430) // and Stage 2 (item_backupper.go itemInclusionChecks). The unit below confirms that // GetNamespaceFilter still returns a filter for the namespace — it is the caller's // responsibility to check ResourceIncludesExcludes first, which item_collector does. // // Full coverage of the Stage 2 enforcement is in item_backupper_test.go // TestItemInclusionChecks_GlobalExclusion_OverridesNamespaceFilter. func TestNamespacedFilterMap_GlobalExclusionPrecedence(t *testing.T) { req := &Request{ Backup: builder.ForBackup("velero", "test-backup").Result(), NamespaceIncludesExcludes: collections.NewNamespaceIncludesExcludes().Includes("ns-a"), NamespacedFilterMap: map[string]*ResolvedNamespaceFilter{ "ns-a": { ResourceFilterMap: map[string]*ResolvedResourceFilter{ "secrets.": {}, }, }, }, NamespacedFilterPatterns: []NamespacedFilterPattern{}, } // GetNamespaceFilter returns the filter regardless of global exclusions. // The caller (item_collector) is responsible for checking ResourceIncludesExcludes first. nsFilter := req.GetNamespaceFilter("ns-a") require.NotNil(t, nsFilter, "GetNamespaceFilter should return a filter for ns-a") _, hasSecrets := nsFilter.ResourceFilterMap["secrets."] assert.True(t, hasSecrets, "ns-a filter should list secrets GR") // When a global excludeAllIE is set, item_collector would return nil before consulting the map. // This is verified by the Stage 1 check: ShouldInclude("secrets.") == false → skip. ie := &excludeAllIE{} assert.False(t, ie.ShouldInclude("secrets."), "global exclusion must reject secrets before the per-namespace filter is consulted") } // excludeAllIE is an IncludesExcludesInterface that excludes every resource kind. type excludeAllIE struct{} func (excludeAllIE) ShouldInclude(string) bool { return false } func (excludeAllIE) ShouldExclude(string) bool { return true } func TestGetResourceItems(t *testing.T) { tests := []struct { name string namespaces []string clusterScopedFilterMap map[string]*ResolvedResourceFilter namespacedFilterMap map[string]*ResolvedNamespaceFilter resource metav1.APIResource gr schema.GroupResource }{ { name: "cluster scoped resource with filter", namespaces: []string{""}, resource: metav1.APIResource{ Name: "persistentvolumes", Namespaced: false, }, gr: schema.GroupResource{Resource: "persistentvolumes"}, clusterScopedFilterMap: map[string]*ResolvedResourceFilter{ "persistentvolumes": { LabelSelector: labels.Set{"app": "foo"}.AsSelector(), }, }, }, { name: "namespace scoped resource with filter", namespaces: []string{"ns1"}, resource: metav1.APIResource{ Name: "pods", Namespaced: true, }, gr: schema.GroupResource{Resource: "pods"}, namespacedFilterMap: map[string]*ResolvedNamespaceFilter{ "ns1": { ResourceFilterMap: map[string]*ResolvedResourceFilter{ "pods": { LabelSelector: labels.Set{"app": "bar"}.AsSelector(), }, }, }, }, }, { name: "namespace scoped resource skipped due to no filter match", namespaces: []string{"ns1"}, resource: metav1.APIResource{ Name: "secrets", Namespaced: true, }, gr: schema.GroupResource{Resource: "secrets"}, namespacedFilterMap: map[string]*ResolvedNamespaceFilter{ "ns1": { ResourceFilterMap: map[string]*ResolvedResourceFilter{ "pods": { LabelSelector: labels.Set{"app": "bar"}.AsSelector(), }, }, }, }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { dc := &test.FakeDynamicClient{} dc.On("List", mock.Anything).Return(&unstructured.UnstructuredList{}, nil) factory := &test.FakeDynamicFactory{} factory.On("ClientForGroupVersionResource", mock.Anything, mock.Anything, mock.Anything).Return(dc, nil) req := &Request{ Backup: builder.ForBackup("velero", "backup").Result(), ClusterScopedFilterMap: tc.clusterScopedFilterMap, NamespacedFilterMap: tc.namespacedFilterMap, ResourceIncludesExcludes: includeAllIE{}, } if len(tc.namespaces) > 0 && tc.namespaces[0] != "" { req.NamespaceIncludesExcludes = collections.NewNamespaceIncludesExcludes().Includes(tc.namespaces...) } else { req.NamespaceIncludesExcludes = collections.NewNamespaceIncludesExcludes().Includes("*") } r := &itemCollector{ backupRequest: req, dynamicFactory: factory, discoveryHelper: test.NewFakeDiscoveryHelper(true, nil), log: test.NewLogger(), } _, err := r.getResourceItems(test.NewLogger(), schema.GroupVersion{}, tc.resource, nil) assert.NoError(t, err) }) } } func TestGetOrderedResourcesForTypeTrimsSpaces(t *testing.T) { // Spaces after commas are common in CLI input and should not break ordering. orders := getOrderedResourcesForType(map[string]string{ "pods": "ns1/pod2, ns1/pod1", }, "pods") require.Equal(t, []string{"ns1/pod2", "ns1/pod1"}, orders) log := logrus.StandardLogger() podResources := []*kubernetesResource{ {namespace: "ns1", name: "pod3"}, {namespace: "ns1", name: "pod1"}, {namespace: "ns1", name: "pod2"}, } sorted := sortResourcesByOrder(log, podResources, orders) require.Equal(t, []*kubernetesResource{ {namespace: "ns1", name: "pod2", orderedResource: true}, {namespace: "ns1", name: "pod1", orderedResource: true}, {namespace: "ns1", name: "pod3"}, }, sorted) } func TestGetResourceItems_NamespacedFilterPolicies_ClusterWideListing(t *testing.T) { // Simulate cluster-wide API calls where client is called with namespace="" prodSA := unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "v1", "kind": "ServiceAccount", "metadata": map[string]any{ "name": "default", "namespace": "production", }, }, } defaultSA := unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "v1", "kind": "ServiceAccount", "metadata": map[string]any{ "name": "default", "namespace": "default", }, }, } kubeSystemSA := unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "v1", "kind": "ServiceAccount", "metadata": map[string]any{ "name": "default", "namespace": "kube-system", }, }, } prodCM1 := unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{ "name": "app-config", "namespace": "production", "labels": map[string]any{ "app": "frontend", }, }, }, } prodCM2 := unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{ "name": "other-config", "namespace": "production", "labels": map[string]any{ "app": "backend", }, }, }, } defaultCM := unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{ "name": "my-config", "namespace": "default", }, }, } dcClusterWideSA := &test.FakeDynamicClient{} dcClusterWideSA.On("List", mock.Anything).Return(&unstructured.UnstructuredList{Items: []unstructured.Unstructured{prodSA, defaultSA, kubeSystemSA}}, nil) dcClusterWideCM := &test.FakeDynamicClient{} dcClusterWideCM.On("List", mock.Anything).Return(&unstructured.UnstructuredList{Items: []unstructured.Unstructured{prodCM1, prodCM2, defaultCM}}, nil) factory := &test.FakeDynamicFactory{} factory.On("ClientForGroupVersionResource", schema.GroupVersion{Version: "v1"}, metav1.APIResource{Name: "serviceaccounts", Namespaced: true, Kind: "ServiceAccount"}, "").Return(dcClusterWideSA, nil) factory.On("ClientForGroupVersionResource", schema.GroupVersion{Version: "v1"}, metav1.APIResource{Name: "configmaps", Namespaced: true, Kind: "ConfigMap"}, "").Return(dcClusterWideCM, nil) frontendSelector, err := metav1.LabelSelectorAsSelector(&metav1.LabelSelector{ MatchLabels: map[string]string{"app": "frontend"}, }) require.NoError(t, err) req := &Request{ Backup: builder.ForBackup("velero", "backup").Result(), NamespaceIncludesExcludes: collections.NewNamespaceIncludesExcludes(). Includes("*"). Excludes("kube-system"), NamespacedFilterMap: map[string]*ResolvedNamespaceFilter{ "production": { ResourceFilterMap: map[string]*ResolvedResourceFilter{ "configmaps": { LabelSelector: frontendSelector, }, }, }, }, ResourceIncludesExcludes: includeAllIE{}, } tempDir := t.TempDir() r := &itemCollector{ backupRequest: req, dynamicFactory: factory, discoveryHelper: test.NewFakeDiscoveryHelper(true, nil), log: test.NewLogger(), dir: tempDir, } // 1. ServiceAccounts: // - production should be skipped because ServiceAccount is not in production's resourceFilters // - kube-system should be skipped because kube-system is in excludedNamespaces // - default should be included saResource := metav1.APIResource{Name: "serviceaccounts", Namespaced: true, Kind: "ServiceAccount"} saItems, err := r.getResourceItems(test.NewLogger(), schema.GroupVersion{Version: "v1"}, saResource, nil) require.NoError(t, err) require.Len(t, saItems, 1) assert.Equal(t, "default", saItems[0].namespace) assert.Equal(t, "default", saItems[0].name) // 2. ConfigMaps: // - production/app-config matches label app=frontend and should be included // - production/other-config has label app=backend and should be skipped by labelSelector // - default/my-config has no filter policy and should be included cmResource := metav1.APIResource{Name: "configmaps", Namespaced: true, Kind: "ConfigMap"} cmItems, err := r.getResourceItems(test.NewLogger(), schema.GroupVersion{Version: "v1"}, cmResource, nil) require.NoError(t, err) require.Len(t, cmItems, 2) var cmNames []string for _, it := range cmItems { cmNames = append(cmNames, it.namespace+"/"+it.name) } assert.ElementsMatch(t, []string{"production/app-config", "default/my-config"}, cmNames) } func TestGetResourceItems_NamespacedFilterPolicies_SpecificNamespaces(t *testing.T) { defaultSA := unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "v1", "kind": "ServiceAccount", "metadata": map[string]any{ "name": "default", "namespace": "default", }, }, } dcDefaultSA := &test.FakeDynamicClient{} dcDefaultSA.On("List", mock.Anything).Return(&unstructured.UnstructuredList{Items: []unstructured.Unstructured{defaultSA}}, nil) factory := &test.FakeDynamicFactory{} // Note: production client is never even requested because production skips ServiceAccount at the loop top! factory.On("ClientForGroupVersionResource", schema.GroupVersion{Version: "v1"}, metav1.APIResource{Name: "serviceaccounts", Namespaced: true, Kind: "ServiceAccount"}, "default").Return(dcDefaultSA, nil) req := &Request{ Backup: builder.ForBackup("velero", "backup").Result(), NamespaceIncludesExcludes: collections.NewNamespaceIncludesExcludes(). Includes("production", "default"), NamespacedFilterMap: map[string]*ResolvedNamespaceFilter{ "production": { ResourceFilterMap: map[string]*ResolvedResourceFilter{ "configmaps": {}, }, }, }, ResourceIncludesExcludes: includeAllIE{}, } tempDir := t.TempDir() r := &itemCollector{ backupRequest: req, dynamicFactory: factory, discoveryHelper: test.NewFakeDiscoveryHelper(true, nil), log: test.NewLogger(), dir: tempDir, } saResource := metav1.APIResource{Name: "serviceaccounts", Namespaced: true, Kind: "ServiceAccount"} saItems, err := r.getResourceItems(test.NewLogger(), schema.GroupVersion{Version: "v1"}, saResource, nil) require.NoError(t, err) require.Len(t, saItems, 1) assert.Equal(t, "default", saItems[0].namespace) assert.Equal(t, "default", saItems[0].name) }