diff --git a/changelogs/unreleased/9255-Joeavaikath b/changelogs/unreleased/9255-Joeavaikath new file mode 100644 index 000000000..4a2560051 --- /dev/null +++ b/changelogs/unreleased/9255-Joeavaikath @@ -0,0 +1,10 @@ +Implement wildcard namespace pattern expansion for backup namespace includes/excludes. + +This change adds support for wildcard patterns (*, ?, [abc], {a,b,c}) in namespace includes and excludes during backup operations. +When wildcard patterns are detected, they are expanded against the list of active namespaces in the cluster before the backup proceeds. + +Key features: +- Wildcard patterns in namespace includes/excludes are automatically detected and expanded +- Pattern validation ensures unsupported patterns (regex, consecutive asterisks) are rejected +- Empty wildcard results (e.g., "invalid*" matching no namespaces) correctly result in empty backups +- Exact namespace names and "*" continue to work as before (no expansion needed) diff --git a/pkg/backup/backup.go b/pkg/backup/backup.go index 528ca0d84..b0d0937dd 100644 --- a/pkg/backup/backup.go +++ b/pkg/backup/backup.go @@ -168,10 +168,39 @@ func NewKubernetesBackupper( }, nil } -// getNamespaceIncludesExcludes returns an IncludesExcludes list containing which namespaces to -// include and exclude from the backup. -func getNamespaceIncludesExcludes(backup *velerov1api.Backup) *collections.IncludesExcludes { - return collections.NewIncludesExcludes().Includes(backup.Spec.IncludedNamespaces...).Excludes(backup.Spec.ExcludedNamespaces...) +// getNamespaceIncludesExcludesAndArgoCDNamespaces returns an IncludesExcludes list containing which namespaces to +// include and exclude from the backup and a list of namespaces managed by ArgoCD. +func getNamespaceIncludesExcludesAndArgoCDNamespaces(backup *velerov1api.Backup, kbClient kbclient.Client) (*collections.NamespaceIncludesExcludes, []string, error) { + nsList := corev1api.NamespaceList{} + activeNamespaces := []string{} + nsManagedByArgoCD := []string{} + if err := kbClient.List(context.Background(), &nsList); err != nil { + return nil, nsManagedByArgoCD, err + } + for _, ns := range nsList.Items { + activeNamespaces = append(activeNamespaces, ns.Name) + } + + // Set ActiveNamespaces first, then set includes/excludes + includesExcludes := collections.NewNamespaceIncludesExcludes(). + ActiveNamespaces(activeNamespaces). + Includes(backup.Spec.IncludedNamespaces...). + Excludes(backup.Spec.ExcludedNamespaces...) + + // Expand wildcards if needed + if err := includesExcludes.ExpandIncludesExcludes(); err != nil { + return nil, []string{}, err + } + + // Check for ArgoCD managed namespaces in the namespaces that will be included + for _, ns := range nsList.Items { + nsLabels := ns.GetLabels() + if len(nsLabels[ArgoCDManagedByNamespaceLabel]) > 0 && includesExcludes.ShouldInclude(ns.Name) { + nsManagedByArgoCD = append(nsManagedByArgoCD, ns.Name) + } + } + + return includesExcludes, nsManagedByArgoCD, nil } func getResourceHooks(hookSpecs []velerov1api.BackupResourceHookSpec, discoveryHelper discovery.Helper) ([]hook.ResourceHook, error) { @@ -245,8 +274,35 @@ func (kb *kubernetesBackupper) BackupWithResolvers( if err := kb.writeBackupVersion(tw); err != nil { return errors.WithStack(err) } + var err error + var nsManagedByArgoCD []string + backupRequest.NamespaceIncludesExcludes, nsManagedByArgoCD, err = getNamespaceIncludesExcludesAndArgoCDNamespaces(backupRequest.Backup, kb.kbClient) + if err != nil { + log.WithError(err).Errorf("error getting namespace includes/excludes") + return err + } + + if backupRequest.NamespaceIncludesExcludes.IsWildcardExpanded() { + expandedIncludes := backupRequest.NamespaceIncludesExcludes.GetIncludes() + expandedExcludes := backupRequest.NamespaceIncludesExcludes.GetExcludes() + + // Get the final namespace list after wildcard expansion + wildcardResult, err := backupRequest.NamespaceIncludesExcludes.ResolveNamespaceList() + if err != nil { + log.WithError(err).Errorf("error resolving namespace list") + return err + } + + log.WithFields(logrus.Fields{ + "expandedIncludes": expandedIncludes, + "expandedExcludes": expandedExcludes, + "wildcardResult": wildcardResult, + "includedCount": len(expandedIncludes), + "excludedCount": len(expandedExcludes), + "resultCount": len(wildcardResult), + }).Info("Successfully expanded wildcard patterns") + } - backupRequest.NamespaceIncludesExcludes = getNamespaceIncludesExcludes(backupRequest.Backup) log.Infof("Including namespaces: %s", backupRequest.NamespaceIncludesExcludes.IncludesString()) log.Infof("Excluding namespaces: %s", backupRequest.NamespaceIncludesExcludes.ExcludesString()) @@ -254,12 +310,8 @@ func (kb *kubernetesBackupper) BackupWithResolvers( // We will check for the existence of a ArgoCD label in the includedNamespaces and add a warning // so that users are at least aware about the existence of argoCD managed ns in their backup // Related Issue: https://github.com/vmware-tanzu/velero/issues/7905 - if len(backupRequest.Spec.IncludedNamespaces) > 0 { - nsManagedByArgoCD := getNamespacesManagedByArgoCD(kb.kbClient, backupRequest.Spec.IncludedNamespaces, log) - - if len(nsManagedByArgoCD) > 0 { - log.Warnf("backup operation may encounter complications and potentially produce undesirable results due to the inclusion of namespaces %v managed by ArgoCD in the backup.", nsManagedByArgoCD) - } + if len(nsManagedByArgoCD) > 0 { + log.Warnf("backup operation may encounter complications and potentially produce undesirable results due to the inclusion of namespaces %v managed by ArgoCD in the backup.", nsManagedByArgoCD) } if collections.UseOldResourceFilters(backupRequest.Spec) { @@ -284,7 +336,6 @@ func (kb *kubernetesBackupper) BackupWithResolvers( log.Infof("Backing up all volumes using pod volume backup: %t", boolptr.IsSetToTrue(backupRequest.Backup.Spec.DefaultVolumesToFsBackup)) - var err error backupRequest.ResourceHooks, err = getResourceHooks(backupRequest.Spec.Hooks.Resources, kb.discoveryHelper) if err != nil { log.WithError(errors.WithStack(err)).Debugf("Error from getResourceHooks") @@ -1256,26 +1307,3 @@ func putVolumeInfos( return backupStore.PutBackupVolumeInfos(backupName, backupVolumeInfoBuf) } - -func getNamespacesManagedByArgoCD(kbClient kbclient.Client, includedNamespaces []string, log logrus.FieldLogger) []string { - var nsManagedByArgoCD []string - - for _, nsName := range includedNamespaces { - ns := corev1api.Namespace{} - if err := kbClient.Get(context.Background(), kbclient.ObjectKey{Name: nsName}, &ns); err != nil { - // check for only those ns that exist and are included in backup - // here we ignore cases like "" or "*" specified under includedNamespaces - if apierrors.IsNotFound(err) { - continue - } - log.WithError(err).Errorf("error getting namespace %s", nsName) - continue - } - - nsLabels := ns.GetLabels() - if len(nsLabels[ArgoCDManagedByNamespaceLabel]) > 0 { - nsManagedByArgoCD = append(nsManagedByArgoCD, nsName) - } - } - return nsManagedByArgoCD -} diff --git a/pkg/backup/item_collector.go b/pkg/backup/item_collector.go index 2338dd77b..3dace71fd 100644 --- a/pkg/backup/item_collector.go +++ b/pkg/backup/item_collector.go @@ -71,7 +71,7 @@ type itemCollector struct { type nsTracker struct { singleLabelSelector labels.Selector orLabelSelector []labels.Selector - namespaceFilter *collections.IncludesExcludes + namespaceFilter *collections.NamespaceIncludesExcludes logger logrus.FieldLogger namespaceMap map[string]bool @@ -103,7 +103,7 @@ func (nt *nsTracker) init( unstructuredNSs []unstructured.Unstructured, singleLabelSelector labels.Selector, orLabelSelector []labels.Selector, - namespaceFilter *collections.IncludesExcludes, + namespaceFilter *collections.NamespaceIncludesExcludes, logger logrus.FieldLogger, ) { if nt.namespaceMap == nil { @@ -635,7 +635,7 @@ func coreGroupResourcePriority(resource string) int { // getNamespacesToList examines ie and resolves the includes and excludes to a full list of // namespaces to list. If ie is nil or it includes *, the result is just "" (list across all // namespaces). Otherwise, the result is a list of every included namespace minus all excluded ones. -func getNamespacesToList(ie *collections.IncludesExcludes) []string { +func getNamespacesToList(ie *collections.NamespaceIncludesExcludes) []string { if ie == nil { return []string{""} } @@ -753,21 +753,28 @@ func (r *itemCollector) collectNamespaces( } unstructuredList, err := resourceClient.List(metav1.ListOptions{}) + + activeNamespacesHashSet := make(map[string]bool) + for _, namespace := range unstructuredList.Items { + activeNamespacesHashSet[namespace.GetName()] = true + } + if err != nil { log.WithError(errors.WithStack(err)).Error("error list namespaces") return nil, errors.WithStack(err) } - for _, includedNSName := range r.backupRequest.Backup.Spec.IncludedNamespaces { + // Change to look at the struct includes/excludes + // In case wildcards are expanded, we need to look at the struct includes/excludes + for _, includedNSName := range r.backupRequest.NamespaceIncludesExcludes.GetIncludes() { nsExists := false // Skip checking the namespace existing when it's "*". if includedNSName == "*" { continue } - for _, unstructuredNS := range unstructuredList.Items { - if unstructuredNS.GetName() == includedNSName { - nsExists = true - } + + if _, ok := activeNamespacesHashSet[includedNSName]; ok { + nsExists = true } if !nsExists { @@ -809,17 +816,18 @@ func (r *itemCollector) collectNamespaces( var items []*kubernetesResource for index := range unstructuredList.Items { + nsName := unstructuredList.Items[index].GetName() + path, err := r.writeToFile(&unstructuredList.Items[index]) if err != nil { - log.WithError(err).Errorf("Error writing item %s to file", - unstructuredList.Items[index].GetName()) + log.WithError(err).Errorf("Error writing item %s to file", nsName) continue } items = append(items, &kubernetesResource{ groupResource: gr, preferredGVR: preferredGVR, - name: unstructuredList.Items[index].GetName(), + name: nsName, path: path, kind: resource.Kind, }) diff --git a/pkg/backup/item_collector_test.go b/pkg/backup/item_collector_test.go index 3bcf4c345..54e2ed4c3 100644 --- a/pkg/backup/item_collector_test.go +++ b/pkg/backup/item_collector_test.go @@ -153,7 +153,7 @@ func TestFilterNamespaces(t *testing.T) { func TestItemCollectorBackupNamespaces(t *testing.T) { tests := []struct { name string - ie *collections.IncludesExcludes + ie *collections.NamespaceIncludesExcludes namespaces []*corev1api.Namespace backup *velerov1api.Backup expectedTrackedNS []string @@ -162,7 +162,7 @@ func TestItemCollectorBackupNamespaces(t *testing.T) { { name: "ns filter by namespace IE filter", backup: builder.ForBackup("velero", "backup").Result(), - ie: collections.NewIncludesExcludes().Includes("ns1"), + ie: collections.NewNamespaceIncludesExcludes().Includes("ns1"), namespaces: []*corev1api.Namespace{ builder.ForNamespace("ns1").Phase(corev1api.NamespaceActive).Result(), builder.ForNamespace("ns2").Phase(corev1api.NamespaceActive).Result(), @@ -174,7 +174,7 @@ func TestItemCollectorBackupNamespaces(t *testing.T) { backup: builder.ForBackup("velero", "backup").LabelSelector(&metav1.LabelSelector{ MatchLabels: map[string]string{"name": "ns1"}, }).Result(), - ie: collections.NewIncludesExcludes().Includes("*"), + 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(), @@ -186,7 +186,7 @@ func TestItemCollectorBackupNamespaces(t *testing.T) { backup: builder.ForBackup("velero", "backup").OrLabelSelector([]*metav1.LabelSelector{ {MatchLabels: map[string]string{"name": "ns1"}}, }).Result(), - ie: collections.NewIncludesExcludes().Includes("*"), + 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(), @@ -198,7 +198,7 @@ func TestItemCollectorBackupNamespaces(t *testing.T) { backup: builder.ForBackup("velero", "backup").LabelSelector(&metav1.LabelSelector{ MatchLabels: map[string]string{"name": "ns1"}, }).Result(), - ie: collections.NewIncludesExcludes().Excludes("ns1"), + 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(), @@ -210,7 +210,7 @@ func TestItemCollectorBackupNamespaces(t *testing.T) { backup: builder.ForBackup("velero", "backup").OrLabelSelector([]*metav1.LabelSelector{ {MatchLabels: map[string]string{"name": "ns1"}}, }).Result(), - ie: collections.NewIncludesExcludes().Excludes("ns1", "ns2"), + 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(), @@ -221,7 +221,7 @@ func TestItemCollectorBackupNamespaces(t *testing.T) { { name: "No ns filters", backup: builder.ForBackup("velero", "backup").Result(), - ie: collections.NewIncludesExcludes().Includes("*"), + 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(), @@ -231,7 +231,7 @@ func TestItemCollectorBackupNamespaces(t *testing.T) { { name: "ns specified by the IncludeNamespaces cannot be found", backup: builder.ForBackup("velero", "backup").IncludedNamespaces("ns1", "invalid", "*").Result(), - ie: collections.NewIncludesExcludes().Includes("ns1", "invalid", "*"), + 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(), @@ -242,7 +242,7 @@ func TestItemCollectorBackupNamespaces(t *testing.T) { { name: "terminating ns should not tracked", backup: builder.ForBackup("velero", "backup").Result(), - ie: collections.NewIncludesExcludes().Includes("ns1", "ns2"), + ie: collections.NewNamespaceIncludesExcludes().Includes("ns1", "ns2"), namespaces: []*corev1api.Namespace{ builder.ForNamespace("ns1").Phase(corev1api.NamespaceTerminating).Result(), builder.ForNamespace("ns2").Phase(corev1api.NamespaceActive).Result(), diff --git a/pkg/backup/request.go b/pkg/backup/request.go index c3dae48a6..4643142b1 100644 --- a/pkg/backup/request.go +++ b/pkg/backup/request.go @@ -57,7 +57,7 @@ type Request struct { *velerov1api.Backup StorageLocation *velerov1api.BackupStorageLocation SnapshotLocations []*velerov1api.VolumeSnapshotLocation - NamespaceIncludesExcludes *collections.IncludesExcludes + NamespaceIncludesExcludes *collections.NamespaceIncludesExcludes ResourceIncludesExcludes collections.IncludesExcludesInterface ResourceHooks []hook.ResourceHook ResolvedActions []framework.BackupItemResolvedActionV2 diff --git a/pkg/cmd/util/output/backup_describer.go b/pkg/cmd/util/output/backup_describer.go index 8777c52f5..932b77cfb 100644 --- a/pkg/cmd/util/output/backup_describer.go +++ b/pkg/cmd/util/output/backup_describer.go @@ -315,8 +315,14 @@ func DescribeBackupSpec(d *Describer, spec velerov1api.BackupSpec) { } // DescribeBackupStatus describes a backup status in human-readable format. -func DescribeBackupStatus(ctx context.Context, kbClient kbclient.Client, d *Describer, backup *velerov1api.Backup, details bool, - insecureSkipTLSVerify bool, caCertPath string, podVolumeBackups []velerov1api.PodVolumeBackup) { +func DescribeBackupStatus(ctx context.Context, + kbClient kbclient.Client, + d *Describer, + backup *velerov1api.Backup, + details bool, + insecureSkipTLSVerify bool, + caCertPath string, + podVolumeBackups []velerov1api.PodVolumeBackup) { status := backup.Status // Status.Version has been deprecated, use Status.FormatVersion diff --git a/pkg/restore/restore.go b/pkg/restore/restore.go index 8f0a75eb3..5287eec21 100644 --- a/pkg/restore/restore.go +++ b/pkg/restore/restore.go @@ -25,6 +25,7 @@ import ( "os/signal" "path/filepath" "reflect" + "slices" "sort" "strings" "sync" @@ -77,6 +78,7 @@ import ( "github.com/vmware-tanzu/velero/pkg/util/filesystem" "github.com/vmware-tanzu/velero/pkg/util/kube" "github.com/vmware-tanzu/velero/pkg/util/results" + "github.com/vmware-tanzu/velero/pkg/util/wildcard" ) const ObjectStatusRestoreAnnotationKey = "velero.io/restore-status" @@ -474,6 +476,12 @@ func (ctx *restoreContext) execute() (results.Result, results.Result) { return warnings, errs } + // Expand wildcard patterns in namespace includes/excludes if needed + if err := ctx.expandNamespaceWildcards(backupResources); err != nil { + errs.AddVeleroError(err) + return warnings, errs + } + // TODO: Remove outer feature flag check to make this feature a default in Velero. if features.IsEnabled(velerov1api.APIGroupVersionsFeatureFlag) { if ctx.backup.Status.FormatVersion >= "1.1.0" { @@ -2378,6 +2386,59 @@ func (ctx *restoreContext) getSelectedRestoreableItems(resource string, original return restorable, warnings, errs } +// extractNamespacesFromBackup extracts all available namespaces from backup resources +func extractNamespacesFromBackup(backupResources map[string]*archive.ResourceItems) []string { + namespaceSet := make(map[string]struct{}) + for _, resource := range backupResources { + for namespace := range resource.ItemsByNamespace { + if namespace != "" { // Skip cluster-scoped resources (empty namespace) + namespaceSet[namespace] = struct{}{} + } + } + } + + namespaces := make([]string, 0, len(namespaceSet)) + for ns := range namespaceSet { + namespaces = append(namespaces, ns) + } + return namespaces +} + +// expandNamespaceWildcards expands wildcard patterns in namespace includes/excludes +// and updates the restore context with the expanded patterns and status +func (ctx *restoreContext) expandNamespaceWildcards(backupResources map[string]*archive.ResourceItems) error { + if !wildcard.ShouldExpandWildcards(ctx.restore.Spec.IncludedNamespaces, ctx.restore.Spec.ExcludedNamespaces) { + return nil + } + + // If `*` is mentioned in restore excludes, something is wrong + if slices.Contains(ctx.restore.Spec.ExcludedNamespaces, "*") { + return errors.New("wildcard '*' is not allowed in restore excludes") + } + + availableNamespaces := extractNamespacesFromBackup(backupResources) + expandedIncludes, expandedExcludes, err := wildcard.ExpandWildcards( + availableNamespaces, + ctx.restore.Spec.IncludedNamespaces, + ctx.restore.Spec.ExcludedNamespaces, + ) + if err != nil { + return errors.Wrap(err, "error expanding wildcard patterns in namespace includes/excludes") + } + + // Update namespace includes/excludes with expanded patterns + ctx.namespaceIncludesExcludes = collections.NewIncludesExcludes(). + Includes(expandedIncludes...). + Excludes(expandedExcludes...) + + selectedNamespaces := wildcard.GetWildcardResult(expandedIncludes, expandedExcludes) + + ctx.log.Infof("Expanded namespace wildcards - includes: %v, excludes: %v, final: %v", + expandedIncludes, expandedExcludes, selectedNamespaces) + + return nil +} + // removeRestoreLabels removes the restore name and the // restored backup's name. func removeRestoreLabels(obj metav1.Object) { diff --git a/pkg/restore/restore_wildcard_test.go b/pkg/restore/restore_wildcard_test.go new file mode 100644 index 000000000..c38ad7dd9 --- /dev/null +++ b/pkg/restore/restore_wildcard_test.go @@ -0,0 +1,241 @@ +/* +Copyright 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 restore + +import ( + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" + "github.com/vmware-tanzu/velero/pkg/archive" +) + +func TestExpandNamespaceWildcards(t *testing.T) { + tests := []struct { + name string + includeNamespaces []string + excludeNamespaces []string + backupResources map[string]*archive.ResourceItems + expectedIncludeMatches []string + expectedExcludeMatches []string + expectedWildcardResult []string + expectedError string + }{ + { + name: "No wildcards - should not expand", + includeNamespaces: []string{"ns1", "ns2"}, + excludeNamespaces: []string{"ns3"}, + backupResources: map[string]*archive.ResourceItems{ + "namespaces": {ItemsByNamespace: map[string][]string{"ns1": {}, "ns2": {}, "ns3": {}}}, + }, + expectedIncludeMatches: nil, + expectedExcludeMatches: nil, + expectedWildcardResult: nil, + }, + { + name: "Simple wildcard include pattern", + includeNamespaces: []string{"test*"}, + excludeNamespaces: []string{}, + backupResources: map[string]*archive.ResourceItems{ + "namespaces": {ItemsByNamespace: map[string][]string{"test1": {}, "test2": {}, "prod1": {}}}, + }, + expectedIncludeMatches: []string{"test1", "test2"}, + expectedExcludeMatches: []string{}, + expectedWildcardResult: []string{"test1", "test2"}, + }, + { + name: "Multiple wildcard patterns", + includeNamespaces: []string{"test*", "dev*"}, + excludeNamespaces: []string{}, + backupResources: map[string]*archive.ResourceItems{ + "namespaces": {ItemsByNamespace: map[string][]string{"test1": {}, "test2": {}, "dev1": {}, "prod1": {}}}, + }, + expectedIncludeMatches: []string{"dev1", "test1", "test2"}, + expectedExcludeMatches: []string{}, + expectedWildcardResult: []string{"dev1", "test1", "test2"}, + }, + { + name: "Wildcard include with wildcard exclude", + includeNamespaces: []string{"test*"}, + excludeNamespaces: []string{"*-temp"}, + backupResources: map[string]*archive.ResourceItems{ + "namespaces": {ItemsByNamespace: map[string][]string{"test1": {}, "test2-temp": {}, "test3": {}}}, + }, + expectedIncludeMatches: []string{"test1", "test2-temp", "test3"}, + expectedExcludeMatches: []string{"test2-temp"}, + expectedWildcardResult: []string{"test1", "test3"}, + }, + { + name: "Wildcard include with literal exclude", + includeNamespaces: []string{"app-*"}, + excludeNamespaces: []string{"app-test"}, + backupResources: map[string]*archive.ResourceItems{ + "namespaces": {ItemsByNamespace: map[string][]string{"app-prod": {}, "app-test": {}, "app-dev": {}}}, + }, + expectedIncludeMatches: []string{"app-dev", "app-prod", "app-test"}, + expectedExcludeMatches: []string{"app-test"}, + expectedWildcardResult: []string{"app-dev", "app-prod"}, + }, + { + name: "Error: wildcard * in excludes", + includeNamespaces: []string{"test*"}, + excludeNamespaces: []string{"*"}, + backupResources: map[string]*archive.ResourceItems{ + "namespaces": {ItemsByNamespace: map[string][]string{"test1": {}}}, + }, + expectedError: "wildcard '*' is not allowed in restore excludes", + }, + { + name: "Empty backup - no matches", + includeNamespaces: []string{"test*"}, + excludeNamespaces: []string{}, + backupResources: map[string]*archive.ResourceItems{}, + expectedIncludeMatches: []string{}, + expectedExcludeMatches: []string{}, + expectedWildcardResult: []string{}, + }, + { + name: "Wildcard with no matches", + includeNamespaces: []string{"nonexistent*"}, + excludeNamespaces: []string{}, + backupResources: map[string]*archive.ResourceItems{ + "namespaces": {ItemsByNamespace: map[string][]string{"test1": {}, "test2": {}}}, + }, + expectedIncludeMatches: []string{}, + expectedExcludeMatches: []string{}, + expectedWildcardResult: []string{}, + }, + { + name: "Complex pattern with prefix and suffix", + includeNamespaces: []string{"app-*-prod"}, + excludeNamespaces: []string{}, + backupResources: map[string]*archive.ResourceItems{ + "namespaces": {ItemsByNamespace: map[string][]string{"app-frontend-prod": {}, "app-backend-prod": {}, "app-frontend-dev": {}}}, + }, + expectedIncludeMatches: []string{"app-backend-prod", "app-frontend-prod"}, + expectedExcludeMatches: []string{}, + expectedWildcardResult: []string{"app-backend-prod", "app-frontend-prod"}, + }, + { + name: "Backup with cluster resources", + includeNamespaces: []string{"test*"}, + excludeNamespaces: []string{}, + backupResources: map[string]*archive.ResourceItems{ + "namespaces": {ItemsByNamespace: map[string][]string{"test1": {}, "test2": {}}}, + "persistentvolumes": {ItemsByNamespace: map[string][]string{"": {}}}, // cluster-scoped + "pods.v1": {ItemsByNamespace: map[string][]string{"test1": {"pod1"}}}, + }, + expectedIncludeMatches: []string{"test1", "test2"}, + expectedExcludeMatches: []string{}, + expectedWildcardResult: []string{"test1", "test2"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + restore := &velerov1api.Restore{ + Spec: velerov1api.RestoreSpec{ + IncludedNamespaces: tc.includeNamespaces, + ExcludedNamespaces: tc.excludeNamespaces, + }, + } + + ctx := &restoreContext{ + restore: restore, + log: logrus.StandardLogger(), + } + + err := ctx.expandNamespaceWildcards(tc.backupResources) + + if tc.expectedError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedError) + return + } + + require.NoError(t, err) + }) + } +} + +func TestExtractNamespacesFromBackup(t *testing.T) { + tests := []struct { + name string + backupResources map[string]*archive.ResourceItems + expected []string + }{ + { + name: "Multiple namespaces in backup", + backupResources: map[string]*archive.ResourceItems{ + "namespaces": {ItemsByNamespace: map[string][]string{"ns1": {}, "ns2": {}, "ns3": {}}}, + }, + expected: []string{"ns1", "ns2", "ns3"}, + }, + { + name: "Namespaces with resources", + backupResources: map[string]*archive.ResourceItems{ + "namespaces": {ItemsByNamespace: map[string][]string{"app1": {}, "app2": {}}}, + "pods.v1": {ItemsByNamespace: map[string][]string{"app1": {"pod1"}}}, + "services.v1": {ItemsByNamespace: map[string][]string{"app2": {"svc1"}}}, + }, + expected: []string{"app1", "app2"}, + }, + { + name: "Mixed cluster and namespaced resources", + backupResources: map[string]*archive.ResourceItems{ + "namespaces": {ItemsByNamespace: map[string][]string{"test": {}}}, + "persistentvolumes": {ItemsByNamespace: map[string][]string{"": {"pv1"}}}, + "clusterroles": {ItemsByNamespace: map[string][]string{"": {"cr1"}}}, + "pods.v1": {ItemsByNamespace: map[string][]string{"test": {"pod1"}}}, + }, + expected: []string{"test"}, + }, + { + name: "Empty backup", + backupResources: map[string]*archive.ResourceItems{}, + expected: []string{}, + }, + { + name: "Only cluster resources", + backupResources: map[string]*archive.ResourceItems{ + "persistentvolumes": {ItemsByNamespace: map[string][]string{"": {"pv1"}}}, + "clusterroles": {ItemsByNamespace: map[string][]string{"": {"cr1"}}}, + "storageclasses": {ItemsByNamespace: map[string][]string{"": {"sc1"}}}, + }, + expected: []string{}, + }, + { + name: "Duplicate namespace entries", + backupResources: map[string]*archive.ResourceItems{ + "namespaces": {ItemsByNamespace: map[string][]string{"app": {}}}, + "pods.v1": {ItemsByNamespace: map[string][]string{"app": {"pod1"}}}, + "configmaps.v1": {ItemsByNamespace: map[string][]string{"app": {"cm1"}}}, + }, + expected: []string{"app"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := extractNamespacesFromBackup(tc.backupResources) + assert.ElementsMatch(t, tc.expected, result) + }) + } +} diff --git a/pkg/util/collections/includes_excludes.go b/pkg/util/collections/includes_excludes.go index 19ef926f0..b3d18d068 100644 --- a/pkg/util/collections/includes_excludes.go +++ b/pkg/util/collections/includes_excludes.go @@ -32,6 +32,7 @@ import ( "github.com/vmware-tanzu/velero/pkg/discovery" "github.com/vmware-tanzu/velero/pkg/kuberesource" "github.com/vmware-tanzu/velero/pkg/util/boolptr" + "github.com/vmware-tanzu/velero/pkg/util/wildcard" ) type globStringSet struct { @@ -55,6 +56,143 @@ func (gss globStringSet) match(match string) bool { return false } +// NamespaceIncludesExcludes adds some features to IncludesExcludes +// to handle namespace-specific functionality. In particular, it +// provides a way to list all namespaces included in order to determine +// overlap between backups, and it will be expanded in the future to +// handle namespace wildcard values +type NamespaceIncludesExcludes struct { + activeNamespaces []string + includesExcludes *IncludesExcludes + wildcardExpanded bool + wildcardResult []string +} + +func NewNamespaceIncludesExcludes() *NamespaceIncludesExcludes { + return &NamespaceIncludesExcludes{ + activeNamespaces: []string{}, + includesExcludes: NewIncludesExcludes(), + } +} + +func (nie *NamespaceIncludesExcludes) ActiveNamespaces(activeNamespaces []string) *NamespaceIncludesExcludes { + nie.activeNamespaces = activeNamespaces + return nie +} + +func (nie *NamespaceIncludesExcludes) IsWildcardExpanded() bool { + return nie.wildcardExpanded +} + +// Includes adds items to the includes list. '*' is a wildcard +// value meaning "include everything". +func (nie *NamespaceIncludesExcludes) Includes(includes ...string) *NamespaceIncludesExcludes { + nie.includesExcludes.Includes(includes...) + return nie +} + +// GetIncludes returns the items in the includes list +func (nie *NamespaceIncludesExcludes) GetIncludes() []string { + return nie.includesExcludes.GetIncludes() +} + +func (nie *NamespaceIncludesExcludes) GetExcludes() []string { + return nie.includesExcludes.GetExcludes() +} + +// SetIncludes sets the includes list to the given list +func (nie *NamespaceIncludesExcludes) SetIncludes(includes []string) *NamespaceIncludesExcludes { + nie.includesExcludes.includes = newGlobStringSet() + nie.includesExcludes.includes.Insert(includes...) + return nie +} + +// SetExcludes sets the excludes list to the given list +func (nie *NamespaceIncludesExcludes) SetExcludes(excludes []string) *NamespaceIncludesExcludes { + nie.includesExcludes.excludes = newGlobStringSet() + nie.includesExcludes.excludes.Insert(excludes...) + return nie +} + +// IncludesString returns a string containing all of the includes, separated by commas, or * if the +// list is empty. +func (nie *NamespaceIncludesExcludes) IncludesString() string { + return nie.includesExcludes.IncludesString() +} + +// Excludes adds items to the includes list. '*' is a wildcard +// value meaning "include everything". +func (nie *NamespaceIncludesExcludes) Excludes(excludes ...string) *NamespaceIncludesExcludes { + nie.includesExcludes.Excludes(excludes...) + return nie +} + +// IncludesString returns a string containing all of the excludes, separated by commas, or * if the +// list is empty. +func (nie *NamespaceIncludesExcludes) ExcludesString() string { + return nie.includesExcludes.ExcludesString() +} + +// ShouldInclude returns whether the specified item should be +// included or not. Everything in the includes list except those +// items in the excludes list should be included. +func (nie *NamespaceIncludesExcludes) ShouldInclude(s string) bool { + // Special case: if wildcard expansion occurred and resulted in an empty includes list, + // it means the wildcard pattern matched nothing, so we should include nothing. + // This differs from the default behavior where an empty includes list means "include everything". + if nie.wildcardExpanded && nie.includesExcludes.includes.Len() == 0 { + return false + } + return nie.includesExcludes.ShouldInclude(s) +} + +// IncludeEverything returns true if the includes list is empty or '*' +// and the excludes list is empty, or false otherwise. +func (nie *NamespaceIncludesExcludes) IncludeEverything() bool { + return nie.includesExcludes.IncludeEverything() +} + +// Attempts to expand wildcard patterns, if any, in the includes and excludes lists. +func (nie *NamespaceIncludesExcludes) ExpandIncludesExcludes() error { + includes := nie.GetIncludes() + excludes := nie.GetExcludes() + + if wildcard.ShouldExpandWildcards(includes, excludes) { + expandedIncludes, expandedExcludes, err := wildcard.ExpandWildcards( + nie.activeNamespaces, includes, excludes) + if err != nil { + return err + } + + nie.SetIncludes(expandedIncludes) + nie.SetExcludes(expandedExcludes) + nie.wildcardExpanded = true + } + + return nil +} + +// ResolveNamespaceList returns a list of all namespaces which will be backed up. +// The second return value indicates whether wildcard expansion was performed. +func (nie *NamespaceIncludesExcludes) ResolveNamespaceList() ([]string, error) { + // Check if this is being called by non-backup processing e.g. backup queue controller + if !nie.wildcardExpanded { + err := nie.ExpandIncludesExcludes() + if err != nil { + return nil, err + } + } + + outNamespaces := []string{} + for _, ns := range nie.activeNamespaces { + if nie.ShouldInclude(ns) { + outNamespaces = append(outNamespaces, ns) + } + } + nie.wildcardResult = outNamespaces + return nie.wildcardResult, nil +} + // IncludesExcludes is a type that manages lists of included // and excluded items. The logic implemented is that everything // in the included list except those items in the excluded list @@ -171,7 +309,7 @@ type IncludesExcludesInterface interface { type GlobalIncludesExcludes struct { resourceFilter IncludesExcludes includeClusterResources *bool - namespaceFilter IncludesExcludes + namespaceFilter NamespaceIncludesExcludes helper discovery.Helper logger logrus.FieldLogger @@ -239,7 +377,7 @@ func (ie *GlobalIncludesExcludes) ShouldExclude(typeName string) bool { return false } -func GetGlobalResourceIncludesExcludes(helper discovery.Helper, logger logrus.FieldLogger, includes, excludes []string, includeClusterResources *bool, nsIncludesExcludes IncludesExcludes) *GlobalIncludesExcludes { +func GetGlobalResourceIncludesExcludes(helper discovery.Helper, logger logrus.FieldLogger, includes, excludes []string, includeClusterResources *bool, nsIncludesExcludes NamespaceIncludesExcludes) *GlobalIncludesExcludes { ret := &GlobalIncludesExcludes{ resourceFilter: *GetResourceIncludesExcludes(helper, includes, excludes), includeClusterResources: includeClusterResources, @@ -254,9 +392,9 @@ func GetGlobalResourceIncludesExcludes(helper discovery.Helper, logger logrus.Fi } type ScopeIncludesExcludes struct { - namespaceScopedResourceFilter IncludesExcludes // namespace-scoped resource filter - clusterScopedResourceFilter IncludesExcludes // cluster-scoped resource filter - namespaceFilter IncludesExcludes // namespace filter + namespaceScopedResourceFilter IncludesExcludes // namespace-scoped resource filter + clusterScopedResourceFilter IncludesExcludes // cluster-scoped resource filter + namespaceFilter NamespaceIncludesExcludes // namespace filter helper discovery.Helper logger logrus.FieldLogger @@ -384,7 +522,7 @@ func (ie *ScopeIncludesExcludes) CombineWithPolicy(policy *resourcepolicies.Incl ie.logger.Infof("Excluding cluster-scoped resources: %s", ie.clusterScopedResourceFilter.ExcludesString()) } -func newScopeIncludesExcludes(nsIncludesExcludes IncludesExcludes, helper discovery.Helper, logger logrus.FieldLogger) *ScopeIncludesExcludes { +func newScopeIncludesExcludes(nsIncludesExcludes NamespaceIncludesExcludes, helper discovery.Helper, logger logrus.FieldLogger) *ScopeIncludesExcludes { ret := &ScopeIncludesExcludes{ namespaceScopedResourceFilter: IncludesExcludes{ includes: newGlobStringSet(), @@ -404,7 +542,7 @@ func newScopeIncludesExcludes(nsIncludesExcludes IncludesExcludes, helper discov // GetScopeResourceIncludesExcludes function is similar with GetResourceIncludesExcludes, // but it's used for scoped Includes/Excludes, and can handle both cluster-scoped and namespace-scoped resources. -func GetScopeResourceIncludesExcludes(helper discovery.Helper, logger logrus.FieldLogger, namespaceIncludes, namespaceExcludes, clusterIncludes, clusterExcludes []string, nsIncludesExcludes IncludesExcludes) *ScopeIncludesExcludes { +func GetScopeResourceIncludesExcludes(helper discovery.Helper, logger logrus.FieldLogger, namespaceIncludes, namespaceExcludes, clusterIncludes, clusterExcludes []string, nsIncludesExcludes NamespaceIncludesExcludes) *ScopeIncludesExcludes { ret := generateScopedIncludesExcludes( namespaceIncludes, namespaceExcludes, @@ -581,7 +719,7 @@ func generateIncludesExcludes(includes, excludes []string, mapFunc func(string) // generateScopedIncludesExcludes function is similar with generateIncludesExcludes, // but it's used for scoped Includes/Excludes. -func generateScopedIncludesExcludes(namespacedIncludes, namespacedExcludes, clusterIncludes, clusterExcludes []string, mapFunc func(string, bool) string, nsIncludesExcludes IncludesExcludes, helper discovery.Helper, logger logrus.FieldLogger) *ScopeIncludesExcludes { +func generateScopedIncludesExcludes(namespacedIncludes, namespacedExcludes, clusterIncludes, clusterExcludes []string, mapFunc func(string, bool) string, nsIncludesExcludes NamespaceIncludesExcludes, helper discovery.Helper, logger logrus.FieldLogger) *ScopeIncludesExcludes { res := newScopeIncludesExcludes(nsIncludesExcludes, helper, logger) generateFilter(res.namespaceScopedResourceFilter.includes, namespacedIncludes, mapFunc, true) diff --git a/pkg/util/collections/includes_excludes_test.go b/pkg/util/collections/includes_excludes_test.go index 34e5f9987..b7fdcd3af 100644 --- a/pkg/util/collections/includes_excludes_test.go +++ b/pkg/util/collections/includes_excludes_test.go @@ -27,6 +27,7 @@ import ( "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/sets" velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" "github.com/vmware-tanzu/velero/pkg/builder" @@ -503,7 +504,7 @@ func TestNamespaceScopedShouldInclude(t *testing.T) { t.Run(tc.name, func(t *testing.T) { discoveryHelper := setupDiscoveryClientWithResources(tc.apiResources) logger := logrus.StandardLogger() - scopeIncludesExcludes := GetScopeResourceIncludesExcludes(discoveryHelper, logger, tc.namespaceScopedIncludes, tc.namespaceScopedExcludes, []string{}, []string{}, *NewIncludesExcludes()) + scopeIncludesExcludes := GetScopeResourceIncludesExcludes(discoveryHelper, logger, tc.namespaceScopedIncludes, tc.namespaceScopedExcludes, []string{}, []string{}, *NewNamespaceIncludesExcludes()) if got := scopeIncludesExcludes.ShouldInclude((tc.item)); got != tc.want { t.Errorf("want %t, got %t", tc.want, got) @@ -676,7 +677,7 @@ func TestClusterScopedShouldInclude(t *testing.T) { t.Run(tc.name, func(t *testing.T) { discoveryHelper := setupDiscoveryClientWithResources(tc.apiResources) logger := logrus.StandardLogger() - nsIncludeExclude := NewIncludesExcludes().Includes(tc.nsIncludes...) + nsIncludeExclude := NewNamespaceIncludesExcludes().Includes(tc.nsIncludes...) scopeIncludesExcludes := GetScopeResourceIncludesExcludes(discoveryHelper, logger, []string{}, []string{}, tc.clusterScopedIncludes, tc.clusterScopedExcludes, *nsIncludeExclude) if got := scopeIncludesExcludes.ShouldInclude((tc.item)); got != tc.want { @@ -732,7 +733,7 @@ func TestGetScopedResourceIncludesExcludes(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { logger := logrus.StandardLogger() - nsIncludeExclude := NewIncludesExcludes() + nsIncludeExclude := NewNamespaceIncludesExcludes() resources := GetScopeResourceIncludesExcludes(setupDiscoveryClientWithResources(tc.apiResources), logger, tc.namespaceScopedIncludes, tc.namespaceScopedExcludes, tc.clusterScopedIncludes, tc.clusterScopedExcludes, *nsIncludeExclude) assert.Equal(t, tc.expectedNamespaceScopedIncludes, resources.namespaceScopedResourceFilter.includes.List()) @@ -830,7 +831,7 @@ func TestScopeIncludesExcludes_CombineWithPolicy(t *testing.T) { t.Run(tc.name, func(t *testing.T) { logger := logrus.StandardLogger() discoveryHelper := setupDiscoveryClientWithResources(apiResources) - sie := GetScopeResourceIncludesExcludes(discoveryHelper, logger, tc.namespaceScopedIncludes, tc.namespaceScopedExcludes, tc.clusterScopedIncludes, tc.clusterScopedExcludes, *NewIncludesExcludes()) + sie := GetScopeResourceIncludesExcludes(discoveryHelper, logger, tc.namespaceScopedIncludes, tc.namespaceScopedExcludes, tc.clusterScopedIncludes, tc.clusterScopedExcludes, *NewNamespaceIncludesExcludes()) sie.CombineWithPolicy(tc.policy) assert.True(t, tc.verify(*sie)) }) @@ -981,15 +982,391 @@ func TestShouldExcluded(t *testing.T) { var ie IncludesExcludesInterface if tc.filterType == "global" { - ie = GetGlobalResourceIncludesExcludes(setupDiscoveryClientWithResources(tc.apiResources), logger, tc.clusterIncludes, tc.clusterExcludes, tc.includeClusterResources, *NewIncludesExcludes()) + ie = GetGlobalResourceIncludesExcludes(setupDiscoveryClientWithResources(tc.apiResources), logger, tc.clusterIncludes, tc.clusterExcludes, tc.includeClusterResources, *NewNamespaceIncludesExcludes()) } else if tc.filterType == "scope" { - ie = GetScopeResourceIncludesExcludes(setupDiscoveryClientWithResources(tc.apiResources), logger, []string{}, []string{}, tc.clusterIncludes, tc.clusterExcludes, *NewIncludesExcludes()) + ie = GetScopeResourceIncludesExcludes(setupDiscoveryClientWithResources(tc.apiResources), logger, []string{}, []string{}, tc.clusterIncludes, tc.clusterExcludes, *NewNamespaceIncludesExcludes()) } assert.Equal(t, tc.resourceIsExcluded, ie.ShouldExclude(tc.resourceName)) }) } } +func TestExpandIncludesExcludes(t *testing.T) { + tests := []struct { + name string + includes []string + excludes []string + activeNamespaces []string + expectedIncludes []string + expectedExcludes []string + expectedWildcardExpanded bool + expectError bool + }{ + { + name: "no wildcards - should not expand", + includes: []string{"default", "kube-system"}, + excludes: []string{"kube-public"}, + activeNamespaces: []string{"default", "kube-system", "kube-public", "test"}, + expectedIncludes: []string{"default", "kube-system"}, + expectedExcludes: []string{"kube-public"}, + expectedWildcardExpanded: false, + expectError: false, + }, + { + name: "asterisk alone - should not expand", + includes: []string{"*"}, + excludes: []string{}, + activeNamespaces: []string{"default", "kube-system", "test"}, + expectedIncludes: []string{"*"}, + expectedExcludes: []string{}, + expectedWildcardExpanded: false, + expectError: false, + }, + { + name: "wildcard in includes - should expand", + includes: []string{"kube-*"}, + excludes: []string{}, + activeNamespaces: []string{"default", "kube-system", "kube-public", "test"}, + expectedIncludes: []string{"kube-system", "kube-public"}, + expectedExcludes: []string{}, + expectedWildcardExpanded: true, + expectError: false, + }, + { + name: "wildcard in excludes - should expand", + includes: []string{"default"}, + excludes: []string{"*-test"}, + activeNamespaces: []string{"default", "kube-test", "app-test", "prod"}, + expectedIncludes: []string{"default"}, + expectedExcludes: []string{"kube-test", "app-test"}, + expectedWildcardExpanded: true, + expectError: false, + }, + { + name: "wildcards in both includes and excludes", + includes: []string{"kube-*", "app-*"}, + excludes: []string{"*-test"}, + activeNamespaces: []string{"kube-system", "kube-test", "app-prod", "app-test", "default"}, + expectedIncludes: []string{"kube-system", "kube-test", "app-prod", "app-test"}, + expectedExcludes: []string{"kube-test", "app-test"}, + expectedWildcardExpanded: true, + expectError: false, + }, + { + name: "wildcard pattern matches nothing", + includes: []string{"nonexistent-*"}, + excludes: []string{}, + activeNamespaces: []string{"default", "kube-system"}, + expectedIncludes: []string{}, + expectedExcludes: []string{}, + expectedWildcardExpanded: true, + expectError: false, + }, + { + name: "mix of wildcards and non-wildcards in includes", + includes: []string{"default", "kube-*"}, + excludes: []string{}, + activeNamespaces: []string{"default", "kube-system", "kube-public", "test"}, + expectedIncludes: []string{"default", "kube-system", "kube-public"}, + expectedExcludes: []string{}, + expectedWildcardExpanded: true, + expectError: false, + }, + { + name: "question mark wildcard", + includes: []string{"test-?"}, + excludes: []string{}, + activeNamespaces: []string{"test-1", "test-2", "test-10", "default"}, + expectedIncludes: []string{"test-1", "test-2"}, + expectedExcludes: []string{}, + expectedWildcardExpanded: true, + expectError: false, + }, + { + name: "brace wildcard pattern", + includes: []string{"app-{prod,dev}"}, + excludes: []string{}, + activeNamespaces: []string{"app-prod", "app-dev", "app-test", "default"}, + expectedIncludes: []string{"app-prod", "app-dev"}, + expectedExcludes: []string{}, + expectedWildcardExpanded: true, + expectError: false, + }, + { + name: "empty activeNamespaces with wildcards", + includes: []string{"kube-*"}, + excludes: []string{}, + activeNamespaces: []string{}, + expectedIncludes: []string{}, + expectedExcludes: []string{}, + expectedWildcardExpanded: true, + expectError: false, + }, + { + name: "invalid wildcard pattern - consecutive asterisks", + includes: []string{"kube-**"}, + excludes: []string{}, + activeNamespaces: []string{"default"}, + expectedIncludes: []string{"kube-**"}, + expectedExcludes: []string{}, + expectedWildcardExpanded: false, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + nie := NewNamespaceIncludesExcludes(). + ActiveNamespaces(tc.activeNamespaces). + Includes(tc.includes...). + Excludes(tc.excludes...) + + err := nie.ExpandIncludesExcludes() + + if tc.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectedWildcardExpanded, nie.IsWildcardExpanded()) + + // Check includes - convert to sets for order-independent comparison + actualIncludes := sets.NewString(nie.GetIncludes()...) + expectedIncludes := sets.NewString(tc.expectedIncludes...) + assert.True(t, actualIncludes.Equal(expectedIncludes), + "includes mismatch: expected %v, got %v", tc.expectedIncludes, nie.GetIncludes()) + + // Check excludes + actualExcludes := sets.NewString(nie.GetExcludes()...) + expectedExcludes := sets.NewString(tc.expectedExcludes...) + assert.True(t, actualExcludes.Equal(expectedExcludes), + "excludes mismatch: expected %v, got %v", tc.expectedExcludes, nie.GetExcludes()) + }) + } +} + +func TestResolveNamespaceList(t *testing.T) { + tests := []struct { + name string + includes []string + excludes []string + activeNamespaces []string + expectedNamespaces []string + preExpandWildcards bool + }{ + { + name: "no includes/excludes - all active namespaces", + includes: []string{}, + excludes: []string{}, + activeNamespaces: []string{"default", "kube-system", "test"}, + expectedNamespaces: []string{"default", "kube-system", "test"}, + }, + { + name: "asterisk includes - all active namespaces", + includes: []string{"*"}, + excludes: []string{}, + activeNamespaces: []string{"default", "kube-system", "test"}, + expectedNamespaces: []string{"default", "kube-system", "test"}, + }, + { + name: "specific includes - only those namespaces", + includes: []string{"default", "test"}, + excludes: []string{}, + activeNamespaces: []string{"default", "kube-system", "test"}, + expectedNamespaces: []string{"default", "test"}, + }, + { + name: "includes with excludes", + includes: []string{"*"}, + excludes: []string{"kube-system"}, + activeNamespaces: []string{"default", "kube-system", "test"}, + expectedNamespaces: []string{"default", "test"}, + }, + { + name: "wildcard includes - expands and filters", + includes: []string{"kube-*"}, + excludes: []string{}, + activeNamespaces: []string{"default", "kube-system", "kube-public", "test"}, + expectedNamespaces: []string{"kube-system", "kube-public"}, + }, + { + name: "wildcard includes with wildcard excludes", + includes: []string{"app-*"}, + excludes: []string{"*-test"}, + activeNamespaces: []string{"app-prod", "app-dev", "app-test", "default"}, + expectedNamespaces: []string{"app-prod", "app-dev"}, + }, + { + name: "wildcard matches nothing - empty result", + includes: []string{"nonexistent-*"}, + excludes: []string{}, + activeNamespaces: []string{"default", "kube-system"}, + expectedNamespaces: []string{}, + }, + { + name: "empty active namespaces", + includes: []string{"*"}, + excludes: []string{}, + activeNamespaces: []string{}, + expectedNamespaces: []string{}, + }, + { + name: "includes namespace not in active namespaces", + includes: []string{"default", "nonexistent"}, + excludes: []string{}, + activeNamespaces: []string{"default", "test"}, + expectedNamespaces: []string{"default"}, + }, + { + name: "excludes all namespaces from includes", + includes: []string{"default", "test"}, + excludes: []string{"default", "test"}, + activeNamespaces: []string{"default", "test", "prod"}, + expectedNamespaces: []string{}, + }, + { + name: "pre-expanded wildcards - should not expand again", + includes: []string{"kube-*"}, + excludes: []string{}, + activeNamespaces: []string{"default", "kube-system", "kube-public"}, + expectedNamespaces: []string{"kube-system", "kube-public"}, + preExpandWildcards: true, + }, + { + name: "complex wildcard pattern", + includes: []string{"app-{prod,dev}", "kube-*"}, + excludes: []string{"*-test"}, + activeNamespaces: []string{"app-prod", "app-dev", "app-test", "kube-system", "kube-test", "default"}, + expectedNamespaces: []string{"app-prod", "app-dev", "kube-system"}, + }, + { + name: "question mark wildcard pattern", + includes: []string{"ns-?"}, + excludes: []string{}, + activeNamespaces: []string{"ns-1", "ns-2", "ns-10", "default"}, + expectedNamespaces: []string{"ns-1", "ns-2"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + nie := NewNamespaceIncludesExcludes(). + ActiveNamespaces(tc.activeNamespaces). + Includes(tc.includes...). + Excludes(tc.excludes...) + + // Pre-expand wildcards if requested + if tc.preExpandWildcards { + err := nie.ExpandIncludesExcludes() + require.NoError(t, err) + } + + namespaces, err := nie.ResolveNamespaceList() + require.NoError(t, err) + + // Convert to sets for order-independent comparison + actualNs := sets.NewString(namespaces...) + expectedNs := sets.NewString(tc.expectedNamespaces...) + assert.True(t, actualNs.Equal(expectedNs), + "namespaces mismatch: expected %v, got %v", tc.expectedNamespaces, namespaces) + }) + } +} + +func TestResolveNamespaceListError(t *testing.T) { + tests := []struct { + name string + includes []string + excludes []string + activeNamespaces []string + }{ + { + name: "invalid wildcard pattern in includes", + includes: []string{"kube-**"}, + excludes: []string{}, + activeNamespaces: []string{"default"}, + }, + { + name: "invalid wildcard pattern in excludes", + includes: []string{"default"}, + excludes: []string{"test-**"}, + activeNamespaces: []string{"default"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + nie := NewNamespaceIncludesExcludes(). + ActiveNamespaces(tc.activeNamespaces). + Includes(tc.includes...). + Excludes(tc.excludes...) + + _, err := nie.ResolveNamespaceList() + assert.Error(t, err) + }) + } +} + +func TestNamespaceIncludesExcludesShouldIncludeAfterWildcardExpansion(t *testing.T) { + tests := []struct { + name string + includes []string + excludes []string + activeNamespaces []string + testNamespace string + expectedResult bool + }{ + { + name: "wildcard expanded to empty includes - should not include anything", + includes: []string{"nonexistent-*"}, + excludes: []string{}, + activeNamespaces: []string{"default", "kube-system"}, + testNamespace: "default", + expectedResult: false, + }, + { + name: "wildcard expanded with matches - should include matched namespace", + includes: []string{"kube-*"}, + excludes: []string{}, + activeNamespaces: []string{"default", "kube-system", "kube-public"}, + testNamespace: "kube-system", + expectedResult: true, + }, + { + name: "wildcard expanded with matches - should not include unmatched namespace", + includes: []string{"kube-*"}, + excludes: []string{}, + activeNamespaces: []string{"default", "kube-system", "kube-public"}, + testNamespace: "default", + expectedResult: false, + }, + { + name: "no wildcard expansion - empty includes means include all", + includes: []string{}, + excludes: []string{}, + activeNamespaces: []string{"default", "kube-system"}, + testNamespace: "default", + expectedResult: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + nie := NewNamespaceIncludesExcludes(). + ActiveNamespaces(tc.activeNamespaces). + Includes(tc.includes...). + Excludes(tc.excludes...) + + err := nie.ExpandIncludesExcludes() + require.NoError(t, err) + + result := nie.ShouldInclude(tc.testNamespace) + assert.Equal(t, tc.expectedResult, result) + }) + } +} + func setupDiscoveryClientWithResources(APIResources []*test.APIResource) *test.FakeDiscoveryHelper { resourcesMap := make(map[schema.GroupVersionResource]schema.GroupVersionResource) resourceList := make([]*metav1.APIResourceList, 0) diff --git a/pkg/util/wildcard/expand.go b/pkg/util/wildcard/expand.go new file mode 100644 index 000000000..632e05aa1 --- /dev/null +++ b/pkg/util/wildcard/expand.go @@ -0,0 +1,175 @@ +package wildcard + +import ( + "errors" + "strings" + + "github.com/gobwas/glob" + "k8s.io/apimachinery/pkg/util/sets" +) + +func ShouldExpandWildcards(includes []string, excludes []string) bool { + wildcardFound := false + for _, include := range includes { + // Special case: "*" alone means "match all" - don't expand + if include == "*" { + return false + } + + if containsWildcardPattern(include) { + wildcardFound = true + } + } + + for _, exclude := range excludes { + if containsWildcardPattern(exclude) { + wildcardFound = true + } + } + + return wildcardFound +} + +// containsWildcardPattern checks if a pattern contains any wildcard symbols +// Supported patterns: *, ?, [abc], {a,b,c} +// Note: . and + are treated as literal characters (not wildcards) +// Note: ** and consecutive asterisks are NOT supported (will cause validation error) +func containsWildcardPattern(pattern string) bool { + return strings.ContainsAny(pattern, "*?[{") +} + +func validateWildcardPatterns(patterns []string) error { + for _, pattern := range patterns { + // Check for invalid regex-only patterns that we don't support + if strings.ContainsAny(pattern, "|()") { + return errors.New("wildcard pattern contains unsupported regex symbols: |, (, )") + } + + // Check for consecutive asterisks (2 or more) + if strings.Contains(pattern, "**") { + return errors.New("wildcard pattern contains consecutive asterisks (only single * allowed)") + } + + // Check for malformed brace patterns + if err := validateBracePatterns(pattern); err != nil { + return err + } + } + return nil +} + +// validateBracePatterns checks for malformed brace patterns like unclosed braces or empty braces +func validateBracePatterns(pattern string) error { + depth := 0 + + for i := 0; i < len(pattern); i++ { + if pattern[i] == '{' { + braceStart := i + depth++ + + // Scan ahead to find the matching closing brace and validate content + for j := i + 1; j < len(pattern) && depth > 0; j++ { + if pattern[j] == '{' { + depth++ + } else if pattern[j] == '}' { + depth-- + if depth == 0 { + // Found matching closing brace - validate content + content := pattern[braceStart+1 : j] + if strings.Trim(content, ", \t") == "" { + return errors.New("wildcard pattern contains empty brace pattern '{}'") + } + // Skip to the closing brace + i = j + break + } + } + } + + // If we exited the loop without finding a match (depth > 0), brace is unclosed + if depth > 0 { + return errors.New("wildcard pattern contains unclosed brace '{'") + } + + // i is now positioned at the closing brace; the outer loop will increment it + } else if pattern[i] == '}' { + // Found a closing brace without a matching opening brace + return errors.New("wildcard pattern contains unmatched closing brace '}'") + } + } + + return nil +} + +func ExpandWildcards(activeNamespaces []string, includes []string, excludes []string) ([]string, []string, error) { + expandedIncludes, err := expandWildcards(includes, activeNamespaces) + if err != nil { + return nil, nil, err + } + + expandedExcludes, err := expandWildcards(excludes, activeNamespaces) + if err != nil { + return nil, nil, err + } + + return expandedIncludes, expandedExcludes, nil +} + +// expands wildcard patterns into a list of namespaces, while normally passing non-wildcard patterns +func expandWildcards(patterns []string, activeNamespaces []string) ([]string, error) { + if len(patterns) == 0 { + return nil, nil + } + + // Validate patterns before processing + if err := validateWildcardPatterns(patterns); err != nil { + return nil, err + } + + matchedSet := make(map[string]struct{}) + + for _, pattern := range patterns { + // If the pattern is a non-wildcard pattern, we can just add it to the result + if !containsWildcardPattern(pattern) { + matchedSet[pattern] = struct{}{} + continue + } + + // Compile glob pattern + g, err := glob.Compile(pattern) + if err != nil { + return nil, err + } + + // Match against all namespaces + for _, ns := range activeNamespaces { + if g.Match(ns) { + matchedSet[ns] = struct{}{} + } + } + } + + // Convert set to slice + result := make([]string, 0, len(matchedSet)) + for ns := range matchedSet { + result = append(result, ns) + } + + return result, nil +} + +// GetWildcardResult returns the final list of namespaces after applying wildcard include/exclude logic +func GetWildcardResult(expandedIncludes []string, expandedExcludes []string) []string { + // Set check: set of expandedIncludes - set of expandedExcludes + expandedIncludesSet := sets.New(expandedIncludes...) + expandedExcludesSet := sets.New(expandedExcludes...) + selectedNamespacesSet := expandedIncludesSet.Difference(expandedExcludesSet) + + // Convert the set to a slice + selectedNamespaces := make([]string, 0, selectedNamespacesSet.Len()) + for ns := range selectedNamespacesSet { + selectedNamespaces = append(selectedNamespaces, ns) + } + + return selectedNamespaces +} diff --git a/pkg/util/wildcard/expand_test.go b/pkg/util/wildcard/expand_test.go new file mode 100644 index 000000000..f6c7ed434 --- /dev/null +++ b/pkg/util/wildcard/expand_test.go @@ -0,0 +1,799 @@ +package wildcard + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestShouldExpandWildcards(t *testing.T) { + tests := []struct { + name string + includes []string + excludes []string + expected bool + }{ + { + name: "no wildcards", + includes: []string{"ns1", "ns2"}, + excludes: []string{"ns3", "ns4"}, + expected: false, + }, + { + name: "includes has star - should not expand", + includes: []string{"*"}, + excludes: []string{"ns1"}, + expected: false, + }, + { + name: "includes has star after a wildcard pattern - should not expand", + includes: []string{"ns*", "*"}, + excludes: []string{"ns1"}, + expected: false, + }, + { + name: "includes has wildcard pattern", + includes: []string{"ns*"}, + excludes: []string{"ns1"}, + expected: true, + }, + { + name: "excludes has wildcard pattern", + includes: []string{"ns1"}, + excludes: []string{"ns*"}, + expected: true, + }, + { + name: "both have wildcard patterns", + includes: []string{"app-*"}, + excludes: []string{"test-*"}, + expected: true, + }, + { + name: "includes has star and wildcard - star takes precedence", + includes: []string{"*", "ns*"}, + excludes: []string{}, + expected: false, + }, + { + name: "double asterisk should be detected as wildcard", + includes: []string{"**"}, + excludes: []string{}, + expected: true, // ** is a wildcard pattern (but will error during validation) + }, + { + name: "empty slices", + includes: []string{}, + excludes: []string{}, + expected: false, + }, + { + name: "complex wildcard patterns", + includes: []string{"*-prod"}, + excludes: []string{"test-*-staging"}, + expected: true, + }, + { + name: "question mark wildcard", + includes: []string{"ns?"}, + excludes: []string{}, + expected: true, // question mark is now considered a wildcard + }, + { + name: "character class wildcard", + includes: []string{"ns[abc]"}, + excludes: []string{}, + expected: true, // character class is considered wildcard + }, + { + name: "brace alternatives wildcard", + includes: []string{"ns{prod,staging}"}, + excludes: []string{}, + expected: true, // brace alternatives are considered wildcard + }, + { + name: "dot is literal - not wildcard", + includes: []string{"app.prod"}, + excludes: []string{}, + expected: false, // dot is literal, not wildcard + }, + { + name: "plus is literal - not wildcard", + includes: []string{"app+"}, + excludes: []string{}, + expected: false, // plus is literal, not wildcard + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ShouldExpandWildcards(tt.includes, tt.excludes) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExpandWildcards(t *testing.T) { + tests := []struct { + name string + activeNamespaces []string + includes []string + excludes []string + expectedIncludes []string + expectedExcludes []string + expectError bool + }{ + { + name: "no wildcards", + activeNamespaces: []string{"ns1", "ns2", "ns3"}, + includes: []string{"ns1", "ns4"}, + excludes: []string{"ns2"}, + expectedIncludes: []string{"ns1", "ns4"}, + expectedExcludes: []string{"ns2"}, + expectError: false, + }, + { + name: "wildcard in includes", + activeNamespaces: []string{"app-prod", "app-staging", "db-prod", "test-ns"}, + includes: []string{"app-*"}, + excludes: []string{"test-ns"}, + expectedIncludes: []string{"app-prod", "app-staging"}, + expectedExcludes: []string{"test-ns"}, + expectError: false, + }, + { + name: "wildcard in excludes", + activeNamespaces: []string{"app-prod", "app-staging", "db-prod", "test-ns"}, + includes: []string{"app-prod"}, + excludes: []string{"*-staging"}, + expectedIncludes: []string{"app-prod"}, + expectedExcludes: []string{"app-staging"}, + expectError: false, + }, + { + name: "wildcards in both", + activeNamespaces: []string{"app-prod", "app-staging", "db-prod", "db-staging", "test-ns"}, + includes: []string{"*-prod"}, + excludes: []string{"*-staging"}, + expectedIncludes: []string{"app-prod", "db-prod"}, + expectedExcludes: []string{"app-staging", "db-staging"}, + expectError: false, + }, + { + name: "star pattern in includes", + activeNamespaces: []string{"ns1", "ns2", "ns3"}, + includes: []string{"*"}, + excludes: []string{}, + expectedIncludes: []string{"ns1", "ns2", "ns3"}, + expectedExcludes: nil, + expectError: false, + }, + { + name: "empty active namespaces", + activeNamespaces: []string{}, + includes: []string{"app-*"}, + excludes: []string{"test-*"}, + expectedIncludes: nil, + expectedExcludes: nil, + expectError: false, + }, + { + name: "empty includes and excludes", + activeNamespaces: []string{"ns1", "ns2"}, + includes: []string{}, + excludes: []string{}, + expectedIncludes: nil, + expectedExcludes: nil, + expectError: false, + }, + { + name: "complex patterns", + activeNamespaces: []string{"my-app-prod", "my-app-staging", "your-app-prod", "system-ns"}, + includes: []string{"*-app-*"}, + excludes: []string{"*-staging"}, + expectedIncludes: []string{"my-app-prod", "my-app-staging", "your-app-prod"}, + expectedExcludes: []string{"my-app-staging"}, + expectError: false, + }, + { + name: "double asterisk should error", + activeNamespaces: []string{"ns1", "ns2", "ns3"}, + includes: []string{"**"}, + excludes: []string{}, + expectedIncludes: nil, + expectedExcludes: nil, + expectError: true, // ** is invalid + }, + { + name: "double asterisk in pattern should error", + activeNamespaces: []string{"ns1", "ns2", "ns3"}, + includes: []string{"app-**"}, + excludes: []string{}, + expectedIncludes: nil, + expectedExcludes: nil, + expectError: true, // app-** contains ** which is invalid + }, + { + name: "question mark patterns", + activeNamespaces: []string{"ns1", "ns2", "ns10", "test"}, + includes: []string{"ns?"}, + excludes: []string{}, + expectedIncludes: []string{"ns1", "ns2"}, // ? matches single character + expectedExcludes: nil, + expectError: false, + }, + { + name: "character class patterns", + activeNamespaces: []string{"nsa", "nsb", "nsc", "nsx", "ns1"}, + includes: []string{"ns[abc]"}, + excludes: []string{}, + expectedIncludes: []string{"nsa", "nsb", "nsc"}, // [abc] matches a, b, or c + expectedExcludes: nil, + expectError: false, + }, + { + name: "brace alternative patterns", + activeNamespaces: []string{"app-prod", "app-staging", "app-dev", "db-prod"}, + includes: []string{"app-{prod,staging}"}, + excludes: []string{}, + expectedIncludes: []string{"app-prod", "app-staging"}, // {prod,staging} matches either + expectedExcludes: nil, + expectError: false, + }, + { + name: "literal dot and plus patterns", + activeNamespaces: []string{"app.prod", "app-prod", "app+", "app"}, + includes: []string{"app.prod", "app+"}, + excludes: []string{}, + expectedIncludes: []string{"app.prod", "app+"}, // . and + are literal + expectedExcludes: nil, + expectError: false, + }, + { + name: "unsupported regex patterns should error", + activeNamespaces: []string{"ns1", "ns2"}, + includes: []string{"ns(1|2)"}, + excludes: []string{}, + expectedIncludes: nil, + expectedExcludes: nil, + expectError: true, // |, (, ) are not supported + }, + { + name: "unclosed brace patterns should error", + activeNamespaces: []string{"app-prod"}, + includes: []string{"app-{prod,staging"}, + excludes: []string{}, + expectedIncludes: nil, + expectedExcludes: nil, + expectError: true, // unclosed brace + }, + { + name: "empty brace patterns should error", + activeNamespaces: []string{"app-prod"}, + includes: []string{"app-{}"}, + excludes: []string{}, + expectedIncludes: nil, + expectedExcludes: nil, + expectError: true, // empty braces + }, + { + name: "unmatched closing brace should error", + activeNamespaces: []string{"app-prod"}, + includes: []string{"app-prod}"}, + excludes: []string{}, + expectedIncludes: nil, + expectedExcludes: nil, + expectError: true, // unmatched closing brace + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + includes, excludes, err := ExpandWildcards(tt.activeNamespaces, tt.includes, tt.excludes) + + if tt.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.ElementsMatch(t, tt.expectedIncludes, includes) + assert.ElementsMatch(t, tt.expectedExcludes, excludes) + }) + } +} + +func TestExpandWildcardsPrivate(t *testing.T) { + tests := []struct { + name string + patterns []string + activeNamespaces []string + expected []string + expectError bool + }{ + { + name: "empty patterns", + patterns: []string{}, + activeNamespaces: []string{"ns1", "ns2"}, + expected: nil, + expectError: false, + }, + { + name: "non-wildcard patterns", + patterns: []string{"ns1", "ns3"}, + activeNamespaces: []string{"ns1", "ns2"}, + expected: []string{"ns1", "ns3"}, // includes ns3 even if not in active + expectError: false, + }, + { + name: "star pattern", + patterns: []string{"*"}, + activeNamespaces: []string{"ns1", "ns2", "ns3"}, + expected: []string{"ns1", "ns2", "ns3"}, + expectError: false, + }, + { + name: "simple wildcard", + patterns: []string{"app-*"}, + activeNamespaces: []string{"app-prod", "app-staging", "db-prod"}, + expected: []string{"app-prod", "app-staging"}, + expectError: false, + }, + { + name: "multiple patterns", + patterns: []string{"app-*", "db-prod", "*-test"}, + activeNamespaces: []string{"app-prod", "app-staging", "db-prod", "service-test", "other"}, + expected: []string{"app-prod", "app-staging", "db-prod", "service-test"}, + expectError: false, + }, + { + name: "wildcard with no matches", + patterns: []string{"missing-*"}, + activeNamespaces: []string{"app-prod", "db-staging"}, + expected: []string{}, // returns empty slice, not nil + expectError: false, + }, + { + name: "brace patterns work correctly", + patterns: []string{"app-{prod,staging}"}, + activeNamespaces: []string{"app-prod", "app-staging", "app-dev", "app-{prod,staging}"}, + expected: []string{"app-prod", "app-staging"}, // brace patterns do expand + expectError: false, + }, + { + name: "duplicate matches from multiple patterns", + patterns: []string{"app-*", "*-prod"}, + activeNamespaces: []string{"app-prod", "app-staging", "db-prod"}, + expected: []string{"app-prod", "app-staging", "db-prod"}, // no duplicates + expectError: false, + }, + { + name: "question mark pattern - glob wildcard", + patterns: []string{"ns?"}, + activeNamespaces: []string{"ns1", "ns2", "ns10"}, + expected: []string{"ns1", "ns2"}, // ? is a glob pattern for single character + expectError: false, + }, + { + name: "character class patterns", + patterns: []string{"ns[12]"}, + activeNamespaces: []string{"ns1", "ns2", "ns3", "nsa"}, + expected: []string{"ns1", "ns2"}, // [12] matches 1 or 2 + expectError: false, + }, + { + name: "character range patterns", + patterns: []string{"ns[a-c]"}, + activeNamespaces: []string{"nsa", "nsb", "nsc", "nsd", "ns1"}, + expected: []string{"nsa", "nsb", "nsc"}, // [a-c] matches a to c + expectError: false, + }, + { + name: "negated character class", + patterns: []string{"ns[!abc]"}, + activeNamespaces: []string{"nsa", "nsb", "nsc", "nsd", "ns1"}, + expected: []string{"nsd", "ns1"}, // [!abc] matches anything except a, b, c + expectError: false, + }, + { + name: "brace alternatives", + patterns: []string{"app-{prod,test}"}, + activeNamespaces: []string{"app-prod", "app-test", "app-staging", "db-prod"}, + expected: []string{"app-prod", "app-test"}, // {prod,test} matches either + expectError: false, + }, + { + name: "double asterisk should error", + patterns: []string{"**"}, + activeNamespaces: []string{"app-prod", "app.staging", "db/prod"}, + expected: nil, + expectError: true, // ** is not allowed + }, + { + name: "literal dot and plus", + patterns: []string{"app.prod", "service+"}, + activeNamespaces: []string{"app.prod", "appXprod", "service+", "service"}, + expected: []string{"app.prod", "service+"}, // . and + are literal + expectError: false, + }, + { + name: "unsupported regex symbols should error", + patterns: []string{"ns(1|2)"}, + activeNamespaces: []string{"ns1", "ns2"}, + expected: nil, + expectError: true, // |, (, ) not supported + }, + { + name: "double asterisk should error", + patterns: []string{"**"}, + activeNamespaces: []string{"ns1", "ns2"}, + expected: nil, + expectError: true, // ** not allowed + }, + { + name: "double asterisk in pattern should error", + patterns: []string{"app-**-prod"}, + activeNamespaces: []string{"app-prod"}, + expected: nil, + expectError: true, // ** not allowed anywhere + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := expandWildcards(tt.patterns, tt.activeNamespaces) + + if tt.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + if tt.expected == nil { + assert.Nil(t, result) + } else if len(tt.expected) == 0 { + assert.Empty(t, result) + } else { + assert.ElementsMatch(t, tt.expected, result) + } + }) + } +} + +func TestValidateBracePatterns(t *testing.T) { + tests := []struct { + name string + pattern string + expectError bool + errorMsg string + }{ + // Valid patterns + { + name: "valid single brace pattern", + pattern: "app-{prod,staging}", + expectError: false, + }, + { + name: "valid brace with single option", + pattern: "app-{prod}", + expectError: false, + }, + { + name: "valid brace with three options", + pattern: "app-{prod,staging,dev}", + expectError: false, + }, + { + name: "valid pattern with text before and after brace", + pattern: "prefix-{a,b}-suffix", + expectError: false, + }, + { + name: "valid pattern with no braces", + pattern: "app-prod", + expectError: false, + }, + { + name: "valid pattern with asterisk", + pattern: "app-*", + expectError: false, + }, + { + name: "valid brace with spaces around content", + pattern: "app-{ prod , staging }", + expectError: false, + }, + { + name: "valid brace with numbers", + pattern: "ns-{1,2,3}", + expectError: false, + }, + { + name: "valid brace with hyphens in options", + pattern: "{app-prod,db-staging}", + expectError: false, + }, + + // Unclosed opening braces + { + name: "unclosed opening brace at end", + pattern: "app-{prod,staging", + expectError: true, + errorMsg: "unclosed brace", + }, + { + name: "unclosed opening brace at start", + pattern: "{prod,staging", + expectError: true, + errorMsg: "unclosed brace", + }, + { + name: "unclosed opening brace in middle", + pattern: "app-{prod-test", + expectError: true, + errorMsg: "unclosed brace", + }, + { + name: "multiple unclosed braces", + pattern: "app-{prod-{staging", + expectError: true, + errorMsg: "unclosed brace", + }, + + // Unmatched closing braces + { + name: "unmatched closing brace at end", + pattern: "app-prod}", + expectError: true, + errorMsg: "unmatched closing brace", + }, + { + name: "unmatched closing brace at start", + pattern: "}app-prod", + expectError: true, + errorMsg: "unmatched closing brace", + }, + { + name: "unmatched closing brace in middle", + pattern: "app-}prod", + expectError: true, + errorMsg: "unmatched closing brace", + }, + { + name: "extra closing brace after valid pair", + pattern: "app-{prod,staging}}", + expectError: true, + errorMsg: "unmatched closing brace", + }, + + // Empty brace patterns + { + name: "completely empty braces", + pattern: "app-{}", + expectError: true, + errorMsg: "empty brace pattern", + }, + { + name: "braces with only spaces", + pattern: "app-{ }", + expectError: true, + errorMsg: "empty brace pattern", + }, + { + name: "braces with only comma", + pattern: "app-{,}", + expectError: true, + errorMsg: "empty brace pattern", + }, + { + name: "braces with only commas", + pattern: "app-{,,,}", + expectError: true, + errorMsg: "empty brace pattern", + }, + { + name: "braces with commas and spaces", + pattern: "app-{ , , }", + expectError: true, + errorMsg: "empty brace pattern", + }, + { + name: "braces with tabs and commas", + pattern: "app-{\t,\t}", + expectError: true, + errorMsg: "empty brace pattern", + }, + { + name: "empty braces at start", + pattern: "{}app-prod", + expectError: true, + errorMsg: "empty brace pattern", + }, + { + name: "empty braces standalone", + pattern: "{}", + expectError: true, + errorMsg: "empty brace pattern", + }, + + // Edge cases + { + name: "empty pattern", + pattern: "", + expectError: false, + }, + { + name: "pattern with only opening brace", + pattern: "{", + expectError: true, + errorMsg: "unclosed brace", + }, + { + name: "pattern with only closing brace", + pattern: "}", + expectError: true, + errorMsg: "unmatched closing brace", + }, + { + name: "valid brace with special characters inside", + pattern: "app-{prod-1,staging_2,dev.3}", + expectError: false, + }, + { + name: "brace with asterisk inside option", + pattern: "app-{prod*,staging}", + expectError: false, + }, + { + name: "multiple valid brace patterns", + pattern: "{app,db}-{prod,staging}", + expectError: false, + }, + { + name: "brace with single character", + pattern: "app-{a}", + expectError: false, + }, + { + name: "brace with trailing comma but has content", + pattern: "app-{prod,staging,}", + expectError: false, // Has content, so it's valid + }, + { + name: "brace with leading comma but has content", + pattern: "app-{,prod,staging}", + expectError: false, // Has content, so it's valid + }, + { + name: "brace with leading comma but has content", + pattern: "app-{{,prod,staging}", + expectError: true, // unclosed brace + }, + { + name: "brace with leading comma but has content", + pattern: "app-{,prod,staging}}", + expectError: true, // unmatched closing brace + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateBracePatterns(tt.pattern) + + if tt.expectError { + require.Error(t, err, "Expected error for pattern: %s", tt.pattern) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg, "Error message should contain: %s", tt.errorMsg) + } + } else { + assert.NoError(t, err, "Expected no error for pattern: %s", tt.pattern) + } + }) + } +} + +// Edge case tests +func TestExpandWildcardsEdgeCases(t *testing.T) { + t.Run("nil inputs", func(t *testing.T) { + includes, excludes, err := ExpandWildcards(nil, nil, nil) + require.NoError(t, err) + assert.Nil(t, includes) + assert.Nil(t, excludes) + }) + + t.Run("empty string patterns", func(t *testing.T) { + activeNamespaces := []string{"ns1", "ns2"} + result, err := expandWildcards([]string{""}, activeNamespaces) + require.NoError(t, err) + assert.ElementsMatch(t, []string{""}, result) // empty string is treated as literal + }) + + t.Run("whitespace patterns", func(t *testing.T) { + activeNamespaces := []string{"ns1", " ", "ns2"} + result, err := expandWildcards([]string{" "}, activeNamespaces) + require.NoError(t, err) + assert.ElementsMatch(t, []string{" "}, result) + }) + + t.Run("special characters in namespace names", func(t *testing.T) { + activeNamespaces := []string{"ns-1", "ns_2", "ns.3", "ns@4"} + result, err := expandWildcards([]string{"ns*"}, activeNamespaces) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"ns-1", "ns_2", "ns.3", "ns@4"}, result) + }) + + t.Run("complex glob combinations", func(t *testing.T) { + activeNamespaces := []string{"app1-prod", "app2-prod", "app1-test", "db-prod", "service"} + result, err := expandWildcards([]string{"app?-{prod,test}"}, activeNamespaces) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"app1-prod", "app2-prod", "app1-test"}, result) + }) + + t.Run("escaped characters", func(t *testing.T) { + activeNamespaces := []string{"app*", "app-prod", "app?test", "app-test"} + result, err := expandWildcards([]string{"app\\*"}, activeNamespaces) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"app*"}, result) + }) + + t.Run("mixed literal and wildcard patterns", func(t *testing.T) { + activeNamespaces := []string{"app.prod", "app-prod", "app_prod", "test.ns"} + result, err := expandWildcards([]string{"app.prod", "app?prod"}, activeNamespaces) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"app.prod", "app-prod", "app_prod"}, result) + }) + + t.Run("conservative asterisk validation", func(t *testing.T) { + tests := []struct { + name string + pattern string + shouldError bool + }{ + {"single asterisk", "*", false}, + {"double asterisk", "**", true}, + {"triple asterisk", "***", true}, + {"quadruple asterisk", "****", true}, + {"mixed with double", "app-**", true}, + {"double in middle", "app-**-prod", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := expandWildcards([]string{tt.pattern}, []string{"test"}) + if tt.shouldError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } + }) + + t.Run("malformed pattern validation", func(t *testing.T) { + tests := []struct { + name string + pattern string + shouldError bool + }{ + {"unclosed bracket", "ns[abc", true}, + {"unclosed brace", "app-{prod,staging", true}, + {"nested unclosed", "ns[a{bc", true}, + {"valid bracket", "ns[abc]", false}, + {"valid brace", "app-{prod,staging}", false}, + {"empty bracket", "ns[]", true}, // empty brackets are invalid + {"empty brace", "app-{}", true}, // empty braces are invalid + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := expandWildcards([]string{tt.pattern}, []string{"test"}) + if tt.shouldError { + assert.Error(t, err, "Expected error for pattern: %s", tt.pattern) + } else { + assert.NoError(t, err, "Expected no error for pattern: %s", tt.pattern) + } + }) + } + }) +} diff --git a/test/e2e/basic/namespace-mapping.go b/test/e2e/basic/namespace-mapping.go index 293bb1796..6816b2df0 100644 --- a/test/e2e/basic/namespace-mapping.go +++ b/test/e2e/basic/namespace-mapping.go @@ -41,19 +41,23 @@ func (n *NamespaceMapping) Init() error { if n.UseVolumeSnapshots { backupType = "snapshot" } - var mappedNS string + var mappedNSSb strings.Builder var mappedNSList []string n.NSIncluded = &[]string{} + for nsNum := 0; nsNum < n.NamespacesTotal; nsNum++ { + if nsNum > 0 { + mappedNSSb.WriteString(",") + } createNSName := fmt.Sprintf("%s-%00000d", n.CaseBaseName, nsNum) *n.NSIncluded = append(*n.NSIncluded, createNSName) - mappedNS = mappedNS + createNSName + ":" + createNSName + "-mapped" + mappedNSSb.WriteString(createNSName) + mappedNSSb.WriteString(":") + mappedNSSb.WriteString(createNSName) + mappedNSSb.WriteString("-mapped") mappedNSList = append(mappedNSList, createNSName+"-mapped") - mappedNS = mappedNS + "," } - mappedNS = strings.TrimRightFunc(mappedNS, func(r rune) bool { - return r == ',' - }) + mappedNS := mappedNSSb.String() n.TestMsg = &TestMSG{ Desc: fmt.Sprintf("Restore namespace %s with namespace mapping by %s test", *n.NSIncluded, backupType),