From 0d719f1d8a53bd8b05b8273fd6acb51ba6c52511 Mon Sep 17 00:00:00 2001 From: Adam Zhang Date: Mon, 1 Jun 2026 17:00:54 +0800 Subject: [PATCH] cli support for fine-grained filter policies add cli support for NamespacedFilterPolicies and ClusterScopedFilterPolicy Signed-off-by: Adam Zhang --- changelogs/unreleased/9881-adam-jian-zhang | 1 + pkg/cmd/util/output/backup_describer.go | 119 ++++++++++++++++++ pkg/cmd/util/output/backup_describer_test.go | 85 +++++++++++++ .../output/backup_structured_describer.go | 86 +++++++++++++ .../backup_structured_describer_test.go | 96 ++++++++++++++ 5 files changed, 387 insertions(+) create mode 100644 changelogs/unreleased/9881-adam-jian-zhang diff --git a/changelogs/unreleased/9881-adam-jian-zhang b/changelogs/unreleased/9881-adam-jian-zhang new file mode 100644 index 000000000..8c37c6062 --- /dev/null +++ b/changelogs/unreleased/9881-adam-jian-zhang @@ -0,0 +1 @@ +Fix issue #9816, add cli support for backup with ClusterScopedFilterPolicy and NamespacedFilterPolicies diff --git a/pkg/cmd/util/output/backup_describer.go b/pkg/cmd/util/output/backup_describer.go index e0637a4bd..22bc9e44c 100644 --- a/pkg/cmd/util/output/backup_describer.go +++ b/pkg/cmd/util/output/backup_describer.go @@ -21,6 +21,7 @@ import ( "context" "encoding/json" "fmt" + "io" "sort" "strconv" "strings" @@ -30,6 +31,7 @@ import ( snapshotv1api "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1" "github.com/pkg/errors" + "github.com/sirupsen/logrus" "github.com/fatih/color" kbclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -40,6 +42,7 @@ import ( "github.com/vmware-tanzu/velero/pkg/cmd/util/downloadrequest" "github.com/vmware-tanzu/velero/pkg/itemoperation" + "github.com/vmware-tanzu/velero/internal/resourcepolicies" "github.com/vmware-tanzu/velero/internal/volume" "github.com/vmware-tanzu/velero/pkg/util/collections" "github.com/vmware-tanzu/velero/pkg/util/results" @@ -91,6 +94,9 @@ func DescribeBackup( if backup.Spec.ResourcePolicy != nil { d.Println() DescribeResourcePolicies(d, backup.Spec.ResourcePolicy) + + // Display fine-grained filter policies if they exist + DescribeFineGrainedFilterPolicies(ctx, kbClient, d, backup) } if backup.Spec.UploaderConfig != nil && backup.Spec.UploaderConfig.ParallelFilesUpload > 0 { @@ -130,6 +136,119 @@ func DescribeResourcePolicies(d *Describer, resPolicies *corev1api.TypedLocalObj d.Printf("\tName:\t%s\n", resPolicies.Name) } +// DescribeFineGrainedFilterPolicies describes cluster-scoped and namespace-scoped filter policies if present +func DescribeFineGrainedFilterPolicies(ctx context.Context, kbClient kbclient.Client, d *Describer, backup *velerov1api.Backup) { + if backup.Spec.ResourcePolicy == nil { + return + } + + // Create a discard logger for the resource policies function since this is CLI output context + discardLogger := logrus.New() + discardLogger.Out = io.Discard + + resourcePolicies, err := resourcepolicies.GetResourcePoliciesFromBackup(*backup, kbClient, discardLogger) + if err != nil { + // Don't fail the describe if we can't read policies, just skip + return + } + + if resourcePolicies == nil { + return + } + + clusterScopedFilterPolicy := resourcePolicies.GetClusterScopedFilterPolicy() + if clusterScopedFilterPolicy != nil { + d.Printf("\nCluster Scoped Filter Policy:\n") + d.Printf(" Resource Filters:\n") + for _, rf := range clusterScopedFilterPolicy.ResourceFilters { + kindsStr := strings.Join(rf.Kinds, ", ") + d.Printf(" %s:\n", kindsStr) + + // Label selector + if len(rf.LabelSelector) > 0 { + selectorStr := formatLabelMap(rf.LabelSelector) + d.Printf(" Label selector: %s\n", selectorStr) + } else if len(rf.OrLabelSelectors) > 0 { + var orStrs []string + for _, ols := range rf.OrLabelSelectors { + orStrs = append(orStrs, formatLabelMap(ols)) + } + d.Printf(" OR label selectors: [%s]\n", strings.Join(orStrs, ", ")) + } else { + d.Printf(" Label selector: \n") + } + + // Name patterns + if len(rf.Names) > 0 { + d.Printf(" Included names: [%s]\n", strings.Join(rf.Names, ", ")) + } else { + d.Printf(" Included names: \n") + } + + if len(rf.ExcludedNames) > 0 { + d.Printf(" Excluded names: [%s]\n", strings.Join(rf.ExcludedNames, ", ")) + } else { + d.Printf(" Excluded names: \n") + } + } + } + + nfPolicies := resourcePolicies.GetNamespacedFilterPolicies() + if len(nfPolicies) > 0 { + d.Printf("\nNamespace-Scoped Filter Policies:\n") + for _, policy := range nfPolicies { + for _, ns := range policy.Namespaces { + d.Printf(" %s:\n", ns) + d.Printf(" Resource Filters:\n") + for _, rf := range policy.ResourceFilters { + var kindsStr string + if rf.IsCatchAll() { + kindsStr = " (all other kinds)" + } else { + kindsStr = strings.Join(rf.Kinds, ", ") + } + d.Printf(" %s:\n", kindsStr) + + // Label selector + if len(rf.LabelSelector) > 0 { + selectorStr := formatLabelMap(rf.LabelSelector) + d.Printf(" Label selector: %s\n", selectorStr) + } else if len(rf.OrLabelSelectors) > 0 { + var orStrs []string + for _, ols := range rf.OrLabelSelectors { + orStrs = append(orStrs, formatLabelMap(ols)) + } + d.Printf(" OR label selectors: [%s]\n", strings.Join(orStrs, ", ")) + } else { + d.Printf(" Label selector: \n") + } + + // Name patterns + if len(rf.Names) > 0 { + d.Printf(" Included names: [%s]\n", strings.Join(rf.Names, ", ")) + } else { + d.Printf(" Included names: \n") + } + + if len(rf.ExcludedNames) > 0 { + d.Printf(" Excluded names: [%s]\n", strings.Join(rf.ExcludedNames, ", ")) + } else { + d.Printf(" Excluded names: \n") + } + } + } + } + } +} + +func formatLabelMap(labelMap map[string]string) string { + var pairs []string + for k, v := range labelMap { + pairs = append(pairs, fmt.Sprintf("%s=%s", k, v)) + } + return strings.Join(pairs, ",") +} + // DescribeUploaderConfigForBackup describes uploader config in human-readable format func DescribeUploaderConfigForBackup(d *Describer, spec velerov1api.BackupSpec) { d.Printf("Uploader config:\n") diff --git a/pkg/cmd/util/output/backup_describer_test.go b/pkg/cmd/util/output/backup_describer_test.go index 0de03bdaa..936b19422 100644 --- a/pkg/cmd/util/output/backup_describer_test.go +++ b/pkg/cmd/util/output/backup_describer_test.go @@ -18,6 +18,7 @@ package output import ( "bytes" + "context" "testing" "text/tabwriter" "time" @@ -25,6 +26,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1api "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" "github.com/vmware-tanzu/velero/internal/volume" velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" @@ -866,3 +869,85 @@ func TestDescribeBackupItemOperation(t *testing.T) { d.out.Flush() assert.Equal(t, expected, d.buf.String()) } + +func TestDescribeFineGrainedFilterPolicies(t *testing.T) { + yamlData := ` +version: v1 +clusterScopedFilterPolicy: + resourceFilters: + - kinds: ["StorageClass"] + labelSelector: {"app": "velero"} + - kinds: ["ClusterRole"] + orLabelSelectors: + - {"app": "velero"} + - {"app": "test"} + names: ["role1"] + excludedNames: ["role2"] +namespacedFilterPolicies: +- namespaces: ["ns1", "ns2"] + resourceFilters: + - kinds: ["Pod", "ConfigMap"] + labelSelector: {"app": "velero"} + - kinds: ["*"] +` + cm := &corev1api.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-policy", + Namespace: "velero", + }, + Data: map[string]string{ + "policy.yaml": yamlData, + }, + } + + client := fake.NewClientBuilder().WithRuntimeObjects(cm).Build() + + backup := builder.ForBackup("velero", "test-backup"). + ResourcePolicies("test-policy").Result() + + d := &Describer{ + Prefix: "", + out: &tabwriter.Writer{}, + buf: &bytes.Buffer{}, + } + d.out.Init(d.buf, 0, 8, 2, ' ', 0) + + DescribeFineGrainedFilterPolicies(context.Background(), client, d, backup) + d.out.Flush() + + expected := ` +Cluster Scoped Filter Policy: + Resource Filters: + StorageClass: + Label selector: app=velero + Included names: + Excluded names: + ClusterRole: + OR label selectors: [app=velero, app=test] + Included names: [role1] + Excluded names: [role2] + +Namespace-Scoped Filter Policies: + ns1: + Resource Filters: + Pod, ConfigMap: + Label selector: app=velero + Included names: + Excluded names: + (all other kinds): + Label selector: + Included names: + Excluded names: + ns2: + Resource Filters: + Pod, ConfigMap: + Label selector: app=velero + Included names: + Excluded names: + (all other kinds): + Label selector: + Included names: + Excluded names: +` + assert.Equal(t, expected, d.buf.String()) +} diff --git a/pkg/cmd/util/output/backup_structured_describer.go b/pkg/cmd/util/output/backup_structured_describer.go index 904afa34e..8ec31b72c 100644 --- a/pkg/cmd/util/output/backup_structured_describer.go +++ b/pkg/cmd/util/output/backup_structured_describer.go @@ -21,13 +21,16 @@ import ( "context" "encoding/json" "fmt" + "io" "strings" + "github.com/sirupsen/logrus" corev1api "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kbclient "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/vmware-tanzu/velero/internal/resourcepolicies" "github.com/vmware-tanzu/velero/internal/volume" velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" "github.com/vmware-tanzu/velero/pkg/cmd/util/cacert" @@ -54,6 +57,7 @@ func DescribeBackupInSF( if backup.Spec.ResourcePolicy != nil { DescribeResourcePoliciesInSF(d, backup.Spec.ResourcePolicy) + DescribeFineGrainedFilterPoliciesInSF(ctx, kbClient, d, backup) } status := backup.Status @@ -222,6 +226,88 @@ func DescribeBackupSpecInSF(d *StructuredDescriber, spec velerov1api.BackupSpec) d.Describe("spec", backupSpecInfo) } +// DescribeFineGrainedFilterPoliciesInSF adds the clusterScopedFilterPolicy +// and namespacedFilterPolicies sections to the structured describer output when present +// in the ResourcePolicy ConfigMap referenced by the backup. +func DescribeFineGrainedFilterPoliciesInSF(ctx context.Context, kbClient kbclient.Client, d *StructuredDescriber, backup *velerov1api.Backup) { + if backup.Spec.ResourcePolicy == nil { + return + } + + discardLogger := logrus.New() + discardLogger.Out = io.Discard + + resPolicies, err := resourcepolicies.GetResourcePoliciesFromBackup(*backup, kbClient, discardLogger) + if err != nil || resPolicies == nil { + return + } + + clusterScopedFilterPolicy := resPolicies.GetClusterScopedFilterPolicy() + if clusterScopedFilterPolicy != nil { + var clusterScopedFilters []map[string]any + for _, rf := range clusterScopedFilterPolicy.ResourceFilters { + entry := map[string]any{ + "kinds": rf.Kinds, + } + if len(rf.LabelSelector) > 0 { + entry["labelSelector"] = rf.LabelSelector + } + if len(rf.OrLabelSelectors) > 0 { + entry["orLabelSelectors"] = rf.OrLabelSelectors + } + if len(rf.Names) > 0 { + entry["names"] = rf.Names + } + if len(rf.ExcludedNames) > 0 { + entry["excludedNames"] = rf.ExcludedNames + } + clusterScopedFilters = append(clusterScopedFilters, entry) + } + d.Describe("clusterScopedFilterPolicy", map[string]any{ + "resourceFilters": clusterScopedFilters, + }) + } + + nfPolicies := resPolicies.GetNamespacedFilterPolicies() + if len(nfPolicies) == 0 { + return + } + + var structuredPolicies []map[string]any + for _, policy := range nfPolicies { + for _, ns := range policy.Namespaces { + var rfEntries []map[string]any + for _, rf := range policy.ResourceFilters { + entry := map[string]any{} + if rf.IsCatchAll() { + entry["kinds"] = []string{} + entry["isCatchAll"] = true + } else { + entry["kinds"] = rf.Kinds + } + if len(rf.LabelSelector) > 0 { + entry["labelSelector"] = rf.LabelSelector + } + if len(rf.OrLabelSelectors) > 0 { + entry["orLabelSelectors"] = rf.OrLabelSelectors + } + if len(rf.Names) > 0 { + entry["names"] = rf.Names + } + if len(rf.ExcludedNames) > 0 { + entry["excludedNames"] = rf.ExcludedNames + } + rfEntries = append(rfEntries, entry) + } + structuredPolicies = append(structuredPolicies, map[string]any{ + "namespace": ns, + "resourceFilters": rfEntries, + }) + } + } + d.Describe("namespacedFilterPolicies", structuredPolicies) +} + // DescribeBackupStatusInSF describes a backup status in structured format. func DescribeBackupStatusInSF(ctx context.Context, kbClient kbclient.Client, d *StructuredDescriber, backup *velerov1api.Backup, details bool, insecureSkipTLSVerify bool, caCertPath string, podVolumeBackups []velerov1api.PodVolumeBackup) { diff --git a/pkg/cmd/util/output/backup_structured_describer_test.go b/pkg/cmd/util/output/backup_structured_describer_test.go index c5ede1b36..77d219f49 100644 --- a/pkg/cmd/util/output/backup_structured_describer_test.go +++ b/pkg/cmd/util/output/backup_structured_describer_test.go @@ -17,6 +17,7 @@ limitations under the License. package output import ( + "context" "reflect" "testing" "time" @@ -24,6 +25,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1api "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" "github.com/vmware-tanzu/velero/internal/volume" velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" @@ -707,3 +710,96 @@ func TestDescribeDeleteBackupRequestsInSF(t *testing.T) { }) } } + +func TestDescribeFineGrainedFilterPoliciesInSF(t *testing.T) { + yamlData := ` +version: v1 +clusterScopedFilterPolicy: + resourceFilters: + - kinds: ["StorageClass"] + labelSelector: {"app": "velero"} + - kinds: ["ClusterRole"] + orLabelSelectors: + - {"app": "velero"} + - {"app": "test"} + names: ["role1"] + excludedNames: ["role2"] +namespacedFilterPolicies: +- namespaces: ["ns1", "ns2"] + resourceFilters: + - kinds: ["Pod", "ConfigMap"] + labelSelector: {"app": "velero"} + - kinds: ["*"] +` + cm := &corev1api.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-policy", + Namespace: "velero", + }, + Data: map[string]string{ + "policy.yaml": yamlData, + }, + } + + client := fake.NewClientBuilder().WithRuntimeObjects(cm).Build() + + backup := builder.ForBackup("velero", "test-backup"). + ResourcePolicies("test-policy").Result() + + sd := &StructuredDescriber{ + output: make(map[string]any), + format: "", + } + + DescribeFineGrainedFilterPoliciesInSF(context.Background(), client, sd, backup) + + expect := map[string]any{ + "clusterScopedFilterPolicy": map[string]any{ + "resourceFilters": []map[string]any{ + { + "kinds": []string{"StorageClass"}, + "labelSelector": map[string]string{"app": "velero"}, + }, + { + "kinds": []string{"ClusterRole"}, + "orLabelSelectors": []map[string]string{ + {"app": "velero"}, + {"app": "test"}, + }, + "names": []string{"role1"}, + "excludedNames": []string{"role2"}, + }, + }, + }, + "namespacedFilterPolicies": []map[string]any{ + { + "namespace": "ns1", + "resourceFilters": []map[string]any{ + { + "kinds": []string{"Pod", "ConfigMap"}, + "labelSelector": map[string]string{"app": "velero"}, + }, + { + "kinds": []string{}, + "isCatchAll": true, + }, + }, + }, + { + "namespace": "ns2", + "resourceFilters": []map[string]any{ + { + "kinds": []string{"Pod", "ConfigMap"}, + "labelSelector": map[string]string{"app": "velero"}, + }, + { + "kinds": []string{}, + "isCatchAll": true, + }, + }, + }, + }, + } + + assert.True(t, reflect.DeepEqual(sd.output, expect)) +}