From 66819faa570b08519de3fd6aa04953f98986a2b3 Mon Sep 17 00:00:00 2001 From: Ran Wurmbrand Date: Wed, 15 Jul 2026 14:46:27 +0300 Subject: [PATCH 1/5] Add e2e tests for split-apply migration with multiple CRBs, CRD, and limited RBAC export Signed-off-by: Ran Wurmbrand --- .../mta_871_multiple_crbs_split_apply_test.go | 146 +++++++++++++++++ .../tier0/mta_872_crd_split_apply_test.go | 152 ++++++++++++++++++ .../tier1/mta_873_limited_rbac_export_test.go | 110 +++++++++++++ e2e-tests/utils/utils.go | 114 ++++++++++++- 4 files changed, 521 insertions(+), 1 deletion(-) create mode 100644 e2e-tests/tests/tier0/mta_871_multiple_crbs_split_apply_test.go create mode 100644 e2e-tests/tests/tier0/mta_872_crd_split_apply_test.go create mode 100644 e2e-tests/tests/tier1/mta_873_limited_rbac_export_test.go diff --git a/e2e-tests/tests/tier0/mta_871_multiple_crbs_split_apply_test.go b/e2e-tests/tests/tier0/mta_871_multiple_crbs_split_apply_test.go new file mode 100644 index 00000000..519cbadd --- /dev/null +++ b/e2e-tests/tests/tier0/mta_871_multiple_crbs_split_apply_test.go @@ -0,0 +1,146 @@ +package e2e + +import ( + "log" + "path/filepath" + + "github.com/konveyor/crane/e2e-tests/config" + . "github.com/konveyor/crane/e2e-tests/framework" + "github.com/konveyor/crane/e2e-tests/utils" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Namespace-admin cluster-level migration", func() { + It("[MTA-871]Should migrate workload with one CR and two CRBs using split apply", Label("tier0"), func() { + appName := "simple-nginx-nopv" + namespace := "simple-nginx-nopv" + serviceName := "my-" + appName + scenario := NewMigrationScenario( + appName, + namespace, + config.K8sDeployBin, + config.CraneBin, + config.SourceContext, + config.TargetContext, + ) + srcAppNonAdmin := scenario.SrcAppNonAdmin + tgtAppNonAdmin := scenario.TgtAppNonAdmin + + srcAppNonAdmin.ExtraVars = map[string]any{ + "non_admin_user": "true", + } + tgtAppNonAdmin.ExtraVars = map[string]any{ + "non_admin_user": "true", + } + + kubectlSrc := scenario.KubectlSrc + kubectlTgt := scenario.KubectlTgt + deniedResources := []string{"clusterrolebindings.yaml"} + if !kubectlSrc.IsOpenShift() { + deniedResources = append(deniedResources, "clusterroles.yaml") + } + paths, err := NewScenarioPaths("crane-na1-*") + Expect(err).NotTo(HaveOccurred()) + NonAdminrunner := scenario.CraneNonAdmin + adminRunner := scenario.Crane + + exportOpts := ExportOptions{Namespace: srcAppNonAdmin.Namespace, ExportDir: paths.ExportDir} + transformOpts := TransformOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir} + applyOpts := ApplyOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir, + OutputDir: paths.OutputDir} + cr := ClusterRole{Name: "crane-cluster-role", Verb: "get,list,watch", Resource: "pods"} + firstCrb := ClusterRoleBinding{Name: "first-crb", ClusterRoleName: cr.Name} + secondCrb := ClusterRoleBinding{Name: "second-crb", ClusterRoleName: cr.Name} + firstSa := ServiceAccount{Name: "first-nginx-sa", Namespace: namespace} + secondSa := ServiceAccount{Name: "second-nginx-sa", Namespace: namespace} + clusterResourcesMatch := []utils.ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: firstCrb.Name}, + {Kind: "ClusterRoleBinding", Name: secondCrb.Name}, + {Kind: "ClusterRole", Name: cr.Name}, + } + By("Granting namespace-admin permissions to non-admin user on source and target") + kubectlSrcNonAdmin, kubectlTgtNonAdmin, rbacCleanup, err := SetupActiveKubectlRunners(scenario, namespace) + Expect(err).NotTo(HaveOccurred()) + + DeferCleanup(func() { + By("Delete test namespace on source and target (wait for completion)") + for _, k := range []KubectlRunner{scenario.KubectlSrc, scenario.KubectlTgt} { + if _, err := k.Run("delete", "namespace", namespace, "--ignore-not-found=true", "--wait=true"); err != nil { + log.Printf("cleanup: failed to delete namespace %q on context %q: %v", namespace, k.Context, err) + } + } + }) + DeferCleanup(rbacCleanup) + DeferCleanup(func() { + if err := ResourceCleanup( + []KubectlRunner{kubectlSrc, kubectlTgt}, []Resource{firstCrb, secondCrb, cr, firstSa, secondSa}); err != nil { + log.Printf("Resources cleanup: %v", err) + } + if err := CleanupScenario(paths.TempDir, srcAppNonAdmin, tgtAppNonAdmin); err != nil { + log.Printf("Scenario cleanup: %v", err) + } + + }) + + By("Deploying app as namespace-admin on source cluster") + Expect(PrepareSourceApp(srcAppNonAdmin, kubectlSrcNonAdmin)).NotTo(HaveOccurred()) + + By("Creating first Service-Account on namespace") + Expect(firstSa.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating second Service-Account on namespace") + Expect(secondSa.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating ClusterRole") + Expect(cr.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating the first crb ClusterRoleBinding") + Expect(firstCrb.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating the second crb ClusterRoleBinding") + Expect(secondCrb.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("first crb: Bind Relevant Service-Account to cluster role") + Expect(firstCrb.AddSubject(kubectlSrc, firstSa)).NotTo(HaveOccurred()) + + By("second crb: Bind Relevant Service-Account to cluster role") + Expect(secondCrb.AddSubject(kubectlSrc, secondSa)).NotTo(HaveOccurred()) + + By("Waiting for source pods and endpoints to drain") + WaitForSourceQuiesce(kubectlSrcNonAdmin, namespace, "app="+appName, serviceName) + + By("Namespace admin phase: Running crane export, transform, apply as namespace-admin") + Expect(RunCranePipelineWithChecks(NonAdminrunner, exportOpts, transformOpts, applyOpts)).NotTo(HaveOccurred()) + + By("Namespace admin phase: Verifying expected cluster-resource failures for the current platform") + Expect(utils.AssertFilesExist(filepath.Join(paths.ExportDir, "failures", namespace), deniedResources)).NotTo(HaveOccurred()) + + By("Namespace admin phase: Verifying no cluster resources in output _cluster directory") + Expect(utils.AssertNoKindsInOutput(paths.OutputDir, []string{"ClusterRole", "ClusterRoleBinding"})).NotTo(HaveOccurred()) + + By("Namespace admin phase: Applying namespace resources to target as namespace-admin") + Expect(kubectlTgtNonAdmin.ApplyDir(filepath.Join(paths.OutputDir, "resources", namespace))).NotTo(HaveOccurred()) + + By("Cluster admin phase: Running crane export, transform, apply as cluster-admin") + //we reuse the same setup so we need to override for the second pipeline run + exportOpts.Overwrite = true + transformOpts.Overwrite = true + applyOpts.Overwrite = true + Expect(RunCranePipelineWithChecks(adminRunner, exportOpts, transformOpts, applyOpts)).NotTo(HaveOccurred()) + + By("Cluster admin phase: Verifying cluster resources in output _cluster directory after cluster Admin phase") + allPresented, err := utils.AssertResourcesExist(filepath.Join(paths.ExportDir, "resources", namespace, "_cluster"), clusterResourcesMatch) + Expect(err).NotTo(HaveOccurred()) + Expect(allPresented).To(BeTrue()) + + By("Cluster admin phase: Applying namespace resources to target as namespace-admin") + Expect(kubectlTgt.ApplyDir(filepath.Join(paths.OutputDir, "resources", "_cluster"))).NotTo(HaveOccurred()) + + By("Scaling target deployment and validating app") + Expect(kubectlTgtNonAdmin.ScaleDeployment(namespace, appName, 1)).NotTo(HaveOccurred()) + Eventually(tgtAppNonAdmin.Validate, "5m", "10s").Should(Succeed()) + + }) + +}) diff --git a/e2e-tests/tests/tier0/mta_872_crd_split_apply_test.go b/e2e-tests/tests/tier0/mta_872_crd_split_apply_test.go new file mode 100644 index 00000000..bf9fc595 --- /dev/null +++ b/e2e-tests/tests/tier0/mta_872_crd_split_apply_test.go @@ -0,0 +1,152 @@ +package e2e + +import ( + "log" + "path/filepath" + + "github.com/konveyor/crane/e2e-tests/config" + . "github.com/konveyor/crane/e2e-tests/framework" + "github.com/konveyor/crane/e2e-tests/utils" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Namespace-admin cluster-level migration", func() { + It("[MTA-872] Should migrate CRD + CR with split apply: cluster-admin applies CRD, namespace-admin applies CR", Label("tier0"), func() { + appName := "simple-nginx-nopv" + namespace := "simple-nginx-nopv" + serviceName := "my-" + appName + scenario := NewMigrationScenario( + appName, + namespace, + config.K8sDeployBin, + config.CraneBin, + config.SourceContext, + config.TargetContext, + ) + srcAppNonAdmin := scenario.SrcAppNonAdmin + tgtAppNonAdmin := scenario.TgtAppNonAdmin + + srcAppNonAdmin.ExtraVars = map[string]any{ + "non_admin_user": "true", + } + tgtAppNonAdmin.ExtraVars = map[string]any{ + "non_admin_user": "true", + } + + kubectlSrc := scenario.KubectlSrc + kubectlTgt := scenario.KubectlTgt + crdYAML, err := utils.ReadTestdataFile("widget_crd.yaml") + Expect(err).NotTo(HaveOccurred()) + crYAML, err := utils.ReadTestdataFile("widget_cr.yaml") + Expect(err).NotTo(HaveOccurred()) + + crd := CustomResourceDefinition{ + Name: "widgets.crane-e2e.example.com", + YAML: crdYAML, + } + cr := CustomResource{ + Name: "test-widget", + Namespace: namespace, + Kind: "Widget", + YAML: crYAML, + Resource: "widgets", + } + paths, err := NewScenarioPaths("crane-*") + + runner := scenario.Crane + Expect(err).NotTo(HaveOccurred()) + + exportOpts := ExportOptions{Namespace: srcAppNonAdmin.Namespace, ExportDir: paths.ExportDir} + transformOpts := TransformOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir} + applyOpts := ApplyOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir, + OutputDir: paths.OutputDir} + + By("Granting namespace-admin permissions to non-admin user on source and target") + kubectlSrcNonAdmin, kubectlTgtNonAdmin, rbacCleanup, err := SetupActiveKubectlRunners(scenario, namespace) + Expect(err).NotTo(HaveOccurred()) + + DeferCleanup(rbacCleanup) + + DeferCleanup(func() { + if err := ResourceCleanup( + []KubectlRunner{kubectlSrc, kubectlTgt}, []Resource{cr, crd}); err != nil { + log.Printf("Resources cleanup: %v", err) + } + if err := CleanupScenario(paths.TempDir, srcAppNonAdmin, tgtAppNonAdmin); err != nil { + log.Printf("Scenario cleanup: %v", err) + } + + }) + By("Deploying app as namespace-admin on source cluster") + err = PrepareSourceApp(srcAppNonAdmin, kubectlSrcNonAdmin) + Expect(err).NotTo(HaveOccurred()) + + By("Creating Widget CRD as cluster-admin") + Expect(crd.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Waiting for CRD to be established") + Expect(crd.WaitForEstablished(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating Widget custom resource as cluster-admin") + Expect(cr.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Waiting for source pods and endpoints to drain") + WaitForSourceQuiesce(kubectlSrc, namespace, "app="+appName, serviceName) + + By("Running crane export, transform, apply as cluster-admin") + Expect(RunCranePipelineWithChecks(runner, exportOpts, transformOpts, applyOpts)).NotTo(HaveOccurred()) + + By("Verifying CRD exists in export _cluster directory") + isCrdPresented, err := utils.AssertResourcesExist(filepath.Join(paths.ExportDir, "resources", namespace, "_cluster"), + []utils.ResourceMatch{ + {Kind: "CustomResourceDefinition", Name: crd.Name}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(isCrdPresented).To(BeTrue()) + + By("Verifying Widget CR exists in namespace export directory") + isCrPresented, err := utils.AssertResourcesExist(filepath.Join(paths.ExportDir, "resources", namespace), + []utils.ResourceMatch{ + {Kind: cr.Kind, Name: cr.Name, Scope: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(isCrPresented).To(BeTrue()) + + By("Creating namespace on target cluster") + Expect(kubectlTgt.CreateNamespace(namespace)).NotTo(HaveOccurred()) + + By("Applying CRD to target as cluster-admin") + Expect(kubectlTgt.ApplyDir(filepath.Join(paths.OutputDir, "resources", "_cluster"))).NotTo(HaveOccurred()) + + By("Waiting for CRD to be established on target") + Expect(crd.WaitForEstablished(kubectlTgt)).NotTo(HaveOccurred()) + + By("Granting namespace-admin permission to manage widgets on target") + _, err = kubectlTgt.Run("create", "role", "widget-admin", "-n", namespace, + "--verb=*", "--resource=widgets.crane-e2e.example.com") + Expect(err).NotTo(HaveOccurred()) + _, err = kubectlTgt.Run("create", "rolebinding", "widget-admin-binding", "-n", namespace, + "--role=widget-admin", "--user=dev") + Expect(err).NotTo(HaveOccurred()) + + By("Applying namespace resources to target as namespace-admin") + Expect(kubectlTgtNonAdmin.ApplyDir(filepath.Join(paths.OutputDir, "resources", namespace))).NotTo(HaveOccurred()) + + By("Verifying Widget CR exists on target") + _, err = kubectlTgtNonAdmin.Run("get", "widget", "test-widget", "-n", namespace) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Widget CR has correct spec values on target") + color, err := kubectlTgtNonAdmin.Run("get", "widget", "test-widget", "-n", namespace, + "-o", "jsonpath={.spec.color}") + Expect(err).NotTo(HaveOccurred()) + Expect(color).To(Equal("blue")) + + By("Scaling target deployment and validating app") + Expect(kubectlTgtNonAdmin.ScaleDeployment(namespace, appName, 1)).NotTo(HaveOccurred()) + Eventually(tgtAppNonAdmin.Validate, "2m", "10s").NotTo(HaveOccurred()) + + }) + +}) diff --git a/e2e-tests/tests/tier1/mta_873_limited_rbac_export_test.go b/e2e-tests/tests/tier1/mta_873_limited_rbac_export_test.go new file mode 100644 index 00000000..665848d9 --- /dev/null +++ b/e2e-tests/tests/tier1/mta_873_limited_rbac_export_test.go @@ -0,0 +1,110 @@ +package e2e + +import ( + "log" + "path/filepath" + + "github.com/konveyor/crane/e2e-tests/config" + . "github.com/konveyor/crane/e2e-tests/framework" + "github.com/konveyor/crane/e2e-tests/utils" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Namespace-admin cluster-level migration", func() { + It("[MTA-873] Should export namespace resources and record failures under limited RBAC", Label("tier1"), func() { + appName := "simple-nginx-nopv" + namespace := "simple-nginx-nopv" + serviceName := "my-" + appName + scenario := NewMigrationScenario( + appName, + namespace, + config.K8sDeployBin, + config.CraneBin, + config.SourceContext, + config.TargetContext, + ) + srcAppNonAdmin := scenario.SrcAppNonAdmin + tgtAppNonAdmin := scenario.TgtAppNonAdmin + + srcAppNonAdmin.ExtraVars = map[string]any{ + "non_admin_user": "true", + } + tgtAppNonAdmin.ExtraVars = map[string]any{ + "non_admin_user": "true", + } + + kubectlSrc := scenario.KubectlSrc + kubectlTgt := scenario.KubectlTgt + deniedResources := []string{"clusterrolebindings.yaml"} + if !kubectlSrc.IsOpenShift() { + deniedResources = append(deniedResources, "clusterroles.yaml") + } + paths, err := NewScenarioPaths("crane-na1-*") + Expect(err).NotTo(HaveOccurred()) + NonAdminrunner := scenario.CraneNonAdmin + + exportOpts := ExportOptions{Namespace: srcAppNonAdmin.Namespace, ExportDir: paths.ExportDir} + transformOpts := TransformOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir} + applyOpts := ApplyOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir, + OutputDir: paths.OutputDir} + cr := ClusterRole{Name: "crane-cluster-role", Verb: "get,list,watch", Resource: "pods"} + firstCrb := ClusterRoleBinding{Name: "first-crb", ClusterRoleName: cr.Name} + firstSa := ServiceAccount{Name: "first-nginx-sa", Namespace: namespace} + + By("Granting namespace-admin permissions to non-admin user on source and target") + kubectlSrcNonAdmin, kubectlTgtNonAdmin, rbacCleanup, err := SetupActiveKubectlRunners(scenario, namespace) + Expect(err).NotTo(HaveOccurred()) + + DeferCleanup(func() { + By("Delete test namespace on source and target (wait for completion)") + for _, k := range []KubectlRunner{scenario.KubectlSrc, scenario.KubectlTgt} { + if _, err := k.Run("delete", "namespace", namespace, "--ignore-not-found=true", "--wait=true"); err != nil { + log.Printf("cleanup: failed to delete namespace %q on context %q: %v", namespace, k.Context, err) + } + } + }) + DeferCleanup(rbacCleanup) + DeferCleanup(func() { + if err := ResourceCleanup( + []KubectlRunner{kubectlSrc, kubectlTgt}, []Resource{firstCrb, cr, firstSa}); err != nil { + log.Printf("Resources cleanup: %v", err) + } + if err := CleanupScenario(paths.TempDir, srcAppNonAdmin, tgtAppNonAdmin); err != nil { + log.Printf("Scenario cleanup: %v", err) + } + + }) + + By("Deploying app as namespace-admin on source cluster") + Expect(PrepareSourceApp(srcAppNonAdmin, kubectlSrcNonAdmin)).NotTo(HaveOccurred()) + + By("Creating Service-Account on namespace") + Expect(firstSa.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating ClusterRole") + Expect(cr.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating the ClusterRoleBinding") + Expect(firstCrb.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Bind Relevant Service-Account to cluster role") + Expect(firstCrb.AddSubject(kubectlSrc, firstSa)).NotTo(HaveOccurred()) + + By("Waiting for source pods and endpoints to drain") + WaitForSourceQuiesce(kubectlSrcNonAdmin, namespace, "app="+appName, serviceName) + + By("Namespace admin: Running crane export, transform, apply as namespace-admin") + Expect(RunCranePipelineWithChecks(NonAdminrunner, exportOpts, transformOpts, applyOpts)).NotTo(HaveOccurred()) + + By("Namespace admin: Verifying expected cluster-resource failures for the current platform") + Expect(utils.AssertFilesExist(filepath.Join(paths.ExportDir, "failures", namespace), deniedResources)).NotTo(HaveOccurred()) + + By("Namespace admin: Verifying no cluster resources in output _cluster directory") + Expect(utils.AssertNoKindsInOutput(paths.OutputDir, []string{"ClusterRole", "ClusterRoleBinding"})).NotTo(HaveOccurred()) + + By("Namespace admin phase: Applying namespace resources to target as namespace-admin") + Expect(kubectlTgtNonAdmin.ValidateApplyDir(paths.OutputDir)).NotTo(HaveOccurred()) + }) + +}) diff --git a/e2e-tests/utils/utils.go b/e2e-tests/utils/utils.go index 299cab6d..d81b8542 100644 --- a/e2e-tests/utils/utils.go +++ b/e2e-tests/utils/utils.go @@ -1358,6 +1358,118 @@ func ParseValidationReport(validateDir string, outputFormat string, report inter return fmt.Errorf("failed to parse JSON report: %w", err) } } - return nil } + +// RemapNamespaceInYAML parses each document in a multi-doc YAML stream, +// replaces srcNamespace with tgtNamespace in metadata.namespace, +// and returns the re-serialized YAML string. +func RemapNamespaceInYAML(content []byte, srcNamespace, tgtNamespace string) (string, error) { + docs, err := parseYAMLDocuments(content) + if err != nil { + return "", fmt.Errorf("parsing YAML documents: %w", err) + } + + var parts []string + for i, doc := range docs { + obj, ok := doc.(map[string]any) + if !ok { + return "", fmt.Errorf("document %d: expected map[string]any, got %T", i, doc) + } + if meta, ok := obj["metadata"].(map[string]any); ok { + if meta["namespace"] == srcNamespace { + meta["namespace"] = tgtNamespace + } + } + out, err := yaml.Marshal(obj) + if err != nil { + return "", fmt.Errorf("marshaling YAML document: %w", err) + } + parts = append(parts, string(out)) + } + return strings.Join(parts, "---\n"), nil +} + +// ResourceMatch defines criteria for matching an exported resource file. +// Crane export filenames follow the pattern: +// +// Cluster-scoped: ___clusterscoped_.yaml +// Namespace-scoped: ____.yaml +// +// Only Kind and Name are required. Group and Version narrow the match +// but must be specified together in order (Group before Version). +type ResourceMatch struct { + Kind string + Name string + Scope string // optional, empty means clusterscoped + Version string // optional, empty means wildcard + Group string // optional, empty means wildcard +} + +func getPrefixAndSuffix(r ResourceMatch) (string, string) { + prefix := r.Kind + "_" + if len(r.Group) > 0 { + prefix = prefix + r.Group + "_" + } + + scope := "clusterscoped" + if r.Scope != "" { + scope = r.Scope + } + // under score is for avoiding missmatch such as: + // ns1_my-crb.yaml could match other-ns_my-crb.yaml. + suffix := "_" + scope + "_" + r.Name + ".yaml" + if len(r.Version) > 0 { + suffix = r.Version + suffix + } + return prefix, suffix +} + +func fileHasPrefixAndSuffix(file, prefix, suffix string) bool { + return strings.HasPrefix(file, prefix) && strings.HasSuffix(file, suffix) +} + +// AssertResourcesExist checks if all specified resources exist in the directory. +// Pass the directory containing the YAML files directly (e.g., the _cluster dir +// for cluster-scoped, or the namespace dir for namespace-scoped resources). +// Returns (true, nil) if all match, (false, nil) if any missing, or (false, err) on error. +func AssertResourcesExist(dir string, resources []ResourceMatch) (bool, error) { + existingFiles, err := ListFilesRecursivelyAsList(dir) + if err != nil || len(existingFiles) == 0 { + return false, err + } + + for _, r := range resources { + prefix, suffix := getPrefixAndSuffix(r) + found := false + for _, file := range existingFiles { + if fileHasPrefixAndSuffix(file, prefix, suffix) { + found = true + break + } + } + if !found { + return false, fmt.Errorf("%v not found", r.Name) + } + } + return true, nil +} + +func AssertResourcesDontExist(dir string, resources []ResourceMatch) (bool, error) { + existingFiles, err := ListFilesRecursivelyAsList(dir) + if err != nil { + return false, err + } + if len(existingFiles) == 0 { + return true, nil + } + for _, r := range resources { + prefix, suffix := getPrefixAndSuffix(r) + for _, file := range existingFiles { + if fileHasPrefixAndSuffix(file, prefix, suffix) { + return false, fmt.Errorf("%v was found", r.Name) + } + } + } + return true, nil +} From 85fc80a6a47cf04d0b5f47c9f0e81bef403718e1 Mon Sep 17 00:00:00 2001 From: Ran Wurmbrand Date: Mon, 20 Jul 2026 16:28:43 +0300 Subject: [PATCH 2/5] addressed comments Signed-off-by: Ran Wurmbrand --- .../mta_871_multiple_crbs_split_apply_test.go | 8 +-- .../tier0/mta_872_crd_split_apply_test.go | 14 +++--- .../tier1/mta_873_limited_rbac_export_test.go | 2 +- e2e-tests/utils/utils.go | 50 +------------------ 4 files changed, 12 insertions(+), 62 deletions(-) diff --git a/e2e-tests/tests/tier0/mta_871_multiple_crbs_split_apply_test.go b/e2e-tests/tests/tier0/mta_871_multiple_crbs_split_apply_test.go index 519cbadd..8ae2e173 100644 --- a/e2e-tests/tests/tier0/mta_871_multiple_crbs_split_apply_test.go +++ b/e2e-tests/tests/tier0/mta_871_multiple_crbs_split_apply_test.go @@ -42,12 +42,12 @@ var _ = Describe("Namespace-admin cluster-level migration", func() { } paths, err := NewScenarioPaths("crane-na1-*") Expect(err).NotTo(HaveOccurred()) - NonAdminrunner := scenario.CraneNonAdmin + NonAdminRunner := scenario.CraneNonAdmin adminRunner := scenario.Crane exportOpts := ExportOptions{Namespace: srcAppNonAdmin.Namespace, ExportDir: paths.ExportDir} transformOpts := TransformOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir} - applyOpts := ApplyOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir, + applyOpts := ApplyOptions{TransformDir: paths.TransformDir, OutputDir: paths.OutputDir} cr := ClusterRole{Name: "crane-cluster-role", Verb: "get,list,watch", Resource: "pods"} firstCrb := ClusterRoleBinding{Name: "first-crb", ClusterRoleName: cr.Name} @@ -111,7 +111,7 @@ var _ = Describe("Namespace-admin cluster-level migration", func() { WaitForSourceQuiesce(kubectlSrcNonAdmin, namespace, "app="+appName, serviceName) By("Namespace admin phase: Running crane export, transform, apply as namespace-admin") - Expect(RunCranePipelineWithChecks(NonAdminrunner, exportOpts, transformOpts, applyOpts)).NotTo(HaveOccurred()) + Expect(RunCranePipelineWithChecks(NonAdminRunner, exportOpts, transformOpts, applyOpts)).NotTo(HaveOccurred()) By("Namespace admin phase: Verifying expected cluster-resource failures for the current platform") Expect(utils.AssertFilesExist(filepath.Join(paths.ExportDir, "failures", namespace), deniedResources)).NotTo(HaveOccurred()) @@ -130,7 +130,7 @@ var _ = Describe("Namespace-admin cluster-level migration", func() { Expect(RunCranePipelineWithChecks(adminRunner, exportOpts, transformOpts, applyOpts)).NotTo(HaveOccurred()) By("Cluster admin phase: Verifying cluster resources in output _cluster directory after cluster Admin phase") - allPresented, err := utils.AssertResourcesExist(filepath.Join(paths.ExportDir, "resources", namespace, "_cluster"), clusterResourcesMatch) + allPresented, err := utils.AssertResourcesExist(filepath.Join(paths.OutputDir, "resources", "_cluster"), clusterResourcesMatch) Expect(err).NotTo(HaveOccurred()) Expect(allPresented).To(BeTrue()) diff --git a/e2e-tests/tests/tier0/mta_872_crd_split_apply_test.go b/e2e-tests/tests/tier0/mta_872_crd_split_apply_test.go index bf9fc595..836fd91e 100644 --- a/e2e-tests/tests/tier0/mta_872_crd_split_apply_test.go +++ b/e2e-tests/tests/tier0/mta_872_crd_split_apply_test.go @@ -52,14 +52,15 @@ var _ = Describe("Namespace-admin cluster-level migration", func() { YAML: crYAML, Resource: "widgets", } + tgtNameSpace := Namespace{Name: namespace} paths, err := NewScenarioPaths("crane-*") + Expect(err).NotTo(HaveOccurred()) runner := scenario.Crane - Expect(err).NotTo(HaveOccurred()) exportOpts := ExportOptions{Namespace: srcAppNonAdmin.Namespace, ExportDir: paths.ExportDir} transformOpts := TransformOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir} - applyOpts := ApplyOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir, + applyOpts := ApplyOptions{TransformDir: paths.TransformDir, OutputDir: paths.OutputDir} By("Granting namespace-admin permissions to non-admin user on source and target") @@ -70,7 +71,7 @@ var _ = Describe("Namespace-admin cluster-level migration", func() { DeferCleanup(func() { if err := ResourceCleanup( - []KubectlRunner{kubectlSrc, kubectlTgt}, []Resource{cr, crd}); err != nil { + []KubectlRunner{kubectlSrc, kubectlTgt}, []Resource{cr, crd, tgtNameSpace}); err != nil { log.Printf("Resources cleanup: %v", err) } if err := CleanupScenario(paths.TempDir, srcAppNonAdmin, tgtAppNonAdmin); err != nil { @@ -114,7 +115,7 @@ var _ = Describe("Namespace-admin cluster-level migration", func() { Expect(isCrPresented).To(BeTrue()) By("Creating namespace on target cluster") - Expect(kubectlTgt.CreateNamespace(namespace)).NotTo(HaveOccurred()) + Expect(tgtNameSpace.Create(kubectlTgt)).NotTo(HaveOccurred()) By("Applying CRD to target as cluster-admin") Expect(kubectlTgt.ApplyDir(filepath.Join(paths.OutputDir, "resources", "_cluster"))).NotTo(HaveOccurred()) @@ -138,10 +139,7 @@ var _ = Describe("Namespace-admin cluster-level migration", func() { Expect(err).NotTo(HaveOccurred()) By("Verifying Widget CR has correct spec values on target") - color, err := kubectlTgtNonAdmin.Run("get", "widget", "test-widget", "-n", namespace, - "-o", "jsonpath={.spec.color}") - Expect(err).NotTo(HaveOccurred()) - Expect(color).To(Equal("blue")) + Expect(cr.AssertField(kubectlTgtNonAdmin, "{.spec.color}", "blue")).NotTo(HaveOccurred()) By("Scaling target deployment and validating app") Expect(kubectlTgtNonAdmin.ScaleDeployment(namespace, appName, 1)).NotTo(HaveOccurred()) diff --git a/e2e-tests/tests/tier1/mta_873_limited_rbac_export_test.go b/e2e-tests/tests/tier1/mta_873_limited_rbac_export_test.go index 665848d9..434ecbb3 100644 --- a/e2e-tests/tests/tier1/mta_873_limited_rbac_export_test.go +++ b/e2e-tests/tests/tier1/mta_873_limited_rbac_export_test.go @@ -46,7 +46,7 @@ var _ = Describe("Namespace-admin cluster-level migration", func() { exportOpts := ExportOptions{Namespace: srcAppNonAdmin.Namespace, ExportDir: paths.ExportDir} transformOpts := TransformOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir} - applyOpts := ApplyOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir, + applyOpts := ApplyOptions{TransformDir: paths.TransformDir, OutputDir: paths.OutputDir} cr := ClusterRole{Name: "crane-cluster-role", Verb: "get,list,watch", Resource: "pods"} firstCrb := ClusterRoleBinding{Name: "first-crb", ClusterRoleName: cr.Name} diff --git a/e2e-tests/utils/utils.go b/e2e-tests/utils/utils.go index d81b8542..96e781d3 100644 --- a/e2e-tests/utils/utils.go +++ b/e2e-tests/utils/utils.go @@ -1361,35 +1361,6 @@ func ParseValidationReport(validateDir string, outputFormat string, report inter return nil } -// RemapNamespaceInYAML parses each document in a multi-doc YAML stream, -// replaces srcNamespace with tgtNamespace in metadata.namespace, -// and returns the re-serialized YAML string. -func RemapNamespaceInYAML(content []byte, srcNamespace, tgtNamespace string) (string, error) { - docs, err := parseYAMLDocuments(content) - if err != nil { - return "", fmt.Errorf("parsing YAML documents: %w", err) - } - - var parts []string - for i, doc := range docs { - obj, ok := doc.(map[string]any) - if !ok { - return "", fmt.Errorf("document %d: expected map[string]any, got %T", i, doc) - } - if meta, ok := obj["metadata"].(map[string]any); ok { - if meta["namespace"] == srcNamespace { - meta["namespace"] = tgtNamespace - } - } - out, err := yaml.Marshal(obj) - if err != nil { - return "", fmt.Errorf("marshaling YAML document: %w", err) - } - parts = append(parts, string(out)) - } - return strings.Join(parts, "---\n"), nil -} - // ResourceMatch defines criteria for matching an exported resource file. // Crane export filenames follow the pattern: // @@ -1435,7 +1406,7 @@ func fileHasPrefixAndSuffix(file, prefix, suffix string) bool { // Returns (true, nil) if all match, (false, nil) if any missing, or (false, err) on error. func AssertResourcesExist(dir string, resources []ResourceMatch) (bool, error) { existingFiles, err := ListFilesRecursivelyAsList(dir) - if err != nil || len(existingFiles) == 0 { + if err != nil { return false, err } @@ -1454,22 +1425,3 @@ func AssertResourcesExist(dir string, resources []ResourceMatch) (bool, error) { } return true, nil } - -func AssertResourcesDontExist(dir string, resources []ResourceMatch) (bool, error) { - existingFiles, err := ListFilesRecursivelyAsList(dir) - if err != nil { - return false, err - } - if len(existingFiles) == 0 { - return true, nil - } - for _, r := range resources { - prefix, suffix := getPrefixAndSuffix(r) - for _, file := range existingFiles { - if fileHasPrefixAndSuffix(file, prefix, suffix) { - return false, fmt.Errorf("%v was found", r.Name) - } - } - } - return true, nil -} From bb2cd7a3c13590ee9c5fd73dd7fbaf0763259bdb Mon Sep 17 00:00:00 2001 From: Ran Wurmbrand Date: Tue, 18 Aug 2026 13:14:03 +0300 Subject: [PATCH 3/5] addressed coderabbit comment Signed-off-by: Ran Wurmbrand --- e2e-tests/utils/utils.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/e2e-tests/utils/utils.go b/e2e-tests/utils/utils.go index 96e781d3..a41ec8db 100644 --- a/e2e-tests/utils/utils.go +++ b/e2e-tests/utils/utils.go @@ -1420,7 +1420,10 @@ func AssertResourcesExist(dir string, resources []ResourceMatch) (bool, error) { } } if !found { - return false, fmt.Errorf("%v not found", r.Name) + return false, fmt.Errorf( + "exported resource kind=%q name=%q group=%q version=%q scope=%q not found in directory %q", + r.Kind, r.Name, r.Group, r.Version, r.Scope, dir, + ) } } return true, nil From e7de995c8ceb3e1215d252567b6f5debb123f490 Mon Sep 17 00:00:00 2001 From: Ran Wurmbrand Date: Tue, 18 Aug 2026 14:51:41 +0300 Subject: [PATCH 4/5] addressed coderabbit comments Signed-off-by: Ran Wurmbrand --- .../mta_871_multiple_crbs_split_apply_test.go | 11 +- e2e-tests/utils/utils_test.go | 209 ++++++++++++++++++ 2 files changed, 214 insertions(+), 6 deletions(-) diff --git a/e2e-tests/tests/tier0/mta_871_multiple_crbs_split_apply_test.go b/e2e-tests/tests/tier0/mta_871_multiple_crbs_split_apply_test.go index 8ae2e173..a33d2718 100644 --- a/e2e-tests/tests/tier0/mta_871_multiple_crbs_split_apply_test.go +++ b/e2e-tests/tests/tier0/mta_871_multiple_crbs_split_apply_test.go @@ -134,13 +134,12 @@ var _ = Describe("Namespace-admin cluster-level migration", func() { Expect(err).NotTo(HaveOccurred()) Expect(allPresented).To(BeTrue()) - By("Cluster admin phase: Applying namespace resources to target as namespace-admin") + By("Cluster admin phase: Applying cluster resources to target as cluster-admin") Expect(kubectlTgt.ApplyDir(filepath.Join(paths.OutputDir, "resources", "_cluster"))).NotTo(HaveOccurred()) - - By("Scaling target deployment and validating app") - Expect(kubectlTgtNonAdmin.ScaleDeployment(namespace, appName, 1)).NotTo(HaveOccurred()) - Eventually(tgtAppNonAdmin.Validate, "5m", "10s").Should(Succeed()) - + Expect(ValidateClusterRBAC(kubectlTgt, []ExpectedClusterRoleBinding{ + {ClusterRoleBindingName: firstCrb.Name, ClusterRoleName: cr.Name, SubjectName: firstSa.Name}, + {ClusterRoleBindingName: secondCrb.Name, ClusterRoleName: cr.Name, SubjectName: secondSa.Name}, + })).NotTo(HaveOccurred()) }) }) diff --git a/e2e-tests/utils/utils_test.go b/e2e-tests/utils/utils_test.go index b4e19c0d..e5ff23e4 100644 --- a/e2e-tests/utils/utils_test.go +++ b/e2e-tests/utils/utils_test.go @@ -1667,3 +1667,212 @@ func TestCompareDirectoryYAMLSemanticsUnordered(t *testing.T) { }) } } + +func TestGetPrefixAndSuffix(t *testing.T) { + cases := []struct { + name string + match ResourceMatch + wantPrefix string + wantSuffix string + }{ + { + name: "kind_and_name_only_defaults_to_clusterscoped", + match: ResourceMatch{Kind: "ClusterRoleBinding", Name: "my-crb"}, + wantPrefix: "ClusterRoleBinding_", + wantSuffix: "_clusterscoped_my-crb.yaml", + }, + { + name: "namespace_scoped", + match: ResourceMatch{Kind: "RoleBinding", Name: "my-rb", Scope: "my-ns"}, + wantPrefix: "RoleBinding_", + wantSuffix: "_my-ns_my-rb.yaml", + }, + { + name: "with_group", + match: ResourceMatch{Kind: "ClusterRole", Name: "cr", Group: "rbac.authorization.k8s.io"}, + wantPrefix: "ClusterRole_rbac.authorization.k8s.io_", + wantSuffix: "_clusterscoped_cr.yaml", + }, + { + name: "with_group_and_version", + match: ResourceMatch{Kind: "ClusterRole", Name: "cr", Group: "rbac.authorization.k8s.io", Version: "v1"}, + wantPrefix: "ClusterRole_rbac.authorization.k8s.io_", + wantSuffix: "v1_clusterscoped_cr.yaml", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + prefix, suffix := getPrefixAndSuffix(tc.match) + if prefix != tc.wantPrefix { + t.Fatalf("prefix = %q, want %q", prefix, tc.wantPrefix) + } + if suffix != tc.wantSuffix { + t.Fatalf("suffix = %q, want %q", suffix, tc.wantSuffix) + } + }) + } +} + +func TestFileHasPrefixAndSuffix(t *testing.T) { + cases := []struct { + name string + file string + prefix string + suffix string + want bool + }{ + { + name: "match", + file: "ClusterRoleBinding_rbac.authorization.k8s.io_v1_clusterscoped_my-crb.yaml", + prefix: "ClusterRoleBinding_", + suffix: "_clusterscoped_my-crb.yaml", + want: true, + }, + { + name: "wrong_prefix", + file: "ClusterRole_rbac.authorization.k8s.io_v1_clusterscoped_my-crb.yaml", + prefix: "ClusterRoleBinding_", + suffix: "_clusterscoped_my-crb.yaml", + want: false, + }, + { + name: "wrong_suffix", + file: "ClusterRoleBinding_rbac.authorization.k8s.io_v1_clusterscoped_other-crb.yaml", + prefix: "ClusterRoleBinding_", + suffix: "_clusterscoped_my-crb.yaml", + want: false, + }, + { + name: "namespace_collision_prevented_by_underscore", + file: "ClusterRoleBinding_rbac.authorization.k8s.io_v1_other-ns_my-crb.yaml", + prefix: "ClusterRoleBinding_", + suffix: "_ns_my-crb.yaml", + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := fileHasPrefixAndSuffix(tc.file, tc.prefix, tc.suffix) + if got != tc.want { + t.Fatalf("fileHasPrefixAndSuffix(%q, %q, %q) = %v, want %v", tc.file, tc.prefix, tc.suffix, got, tc.want) + } + }) + } +} + +func TestAssertResourcesExist(t *testing.T) { + cases := []struct { + name string + files []string + resources []ResourceMatch + wantFound bool + wantErr bool + errContains []string + }{ + { + name: "cluster_scoped_match", + files: []string{"ClusterRoleBinding_rbac.authorization.k8s.io_v1_clusterscoped_first-crb.yaml"}, + resources: []ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: "first-crb"}, + }, + wantFound: true, + }, + { + name: "multiple_resources_all_present", + files: []string{ + "ClusterRoleBinding_rbac.authorization.k8s.io_v1_clusterscoped_first-crb.yaml", + "ClusterRoleBinding_rbac.authorization.k8s.io_v1_clusterscoped_second-crb.yaml", + "ClusterRole_rbac.authorization.k8s.io_v1_clusterscoped_crane-cluster-role.yaml", + }, + resources: []ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: "first-crb"}, + {Kind: "ClusterRoleBinding", Name: "second-crb"}, + {Kind: "ClusterRole", Name: "crane-cluster-role"}, + }, + wantFound: true, + }, + { + name: "namespace_scoped_match", + files: []string{"RoleBinding_rbac.authorization.k8s.io_v1_my-ns_my-rb.yaml"}, + resources: []ResourceMatch{ + {Kind: "RoleBinding", Name: "my-rb", Scope: "my-ns"}, + }, + wantFound: true, + }, + { + name: "missing_resource", + files: []string{"ClusterRole_rbac.authorization.k8s.io_v1_clusterscoped_existing.yaml"}, + resources: []ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: "missing-crb"}, + }, + wantFound: false, + wantErr: true, + errContains: []string{"not found"}, + }, + { + name: "namespace_collision_no_false_match", + files: []string{"ClusterRoleBinding_rbac.authorization.k8s.io_v1_other-ns_my-crb.yaml"}, + resources: []ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: "my-crb", Scope: "ns"}, + }, + wantFound: false, + wantErr: true, + errContains: []string{"not found"}, + }, + { + name: "with_group_filter", + files: []string{"ClusterRole_rbac.authorization.k8s.io_v1_clusterscoped_cr.yaml"}, + resources: []ResourceMatch{ + {Kind: "ClusterRole", Name: "cr", Group: "rbac.authorization.k8s.io"}, + }, + wantFound: true, + }, + { + name: "with_group_and_version_filter", + files: []string{"ClusterRole_rbac.authorization.k8s.io_v1_clusterscoped_cr.yaml"}, + resources: []ResourceMatch{ + {Kind: "ClusterRole", Name: "cr", Group: "rbac.authorization.k8s.io", Version: "v1"}, + }, + wantFound: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + for _, f := range tc.files { + if err := os.WriteFile(filepath.Join(dir, f), []byte("placeholder"), 0o644); err != nil { + t.Fatal(err) + } + } + + found, err := AssertResourcesExist(dir, tc.resources) + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + for _, s := range tc.errContains { + if !strings.Contains(err.Error(), s) { + t.Fatalf("error %q does not contain %q", err.Error(), s) + } + } + } else if err != nil { + t.Fatalf("AssertResourcesExist: %v", err) + } + if found != tc.wantFound { + t.Fatalf("found = %v, want %v", found, tc.wantFound) + } + }) + } + + t.Run("missing_directory", func(t *testing.T) { + _, err := AssertResourcesExist("/nonexistent-dir-for-test", []ResourceMatch{ + {Kind: "ClusterRole", Name: "cr"}, + }) + if err == nil { + t.Fatal("expected error for missing directory, got nil") + } + }) +} From b49ea7b7fa2cff5db9f46741951a32bb349a588e Mon Sep 17 00:00:00 2001 From: Ran Wurmbrand Date: Tue, 18 Aug 2026 16:41:49 +0300 Subject: [PATCH 5/5] addressed coderabbit comments Signed-off-by: Ran Wurmbrand --- e2e-tests/utils/utils_test.go | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/e2e-tests/utils/utils_test.go b/e2e-tests/utils/utils_test.go index e5ff23e4..02e8bfae 100644 --- a/e2e-tests/utils/utils_test.go +++ b/e2e-tests/utils/utils_test.go @@ -1837,6 +1837,26 @@ func TestAssertResourcesExist(t *testing.T) { }, wantFound: true, }, + { + name: "wrong_group_rejected", + files: []string{"ClusterRole_rbac.authorization.k8s.io_v1_clusterscoped_cr.yaml"}, + resources: []ResourceMatch{ + {Kind: "ClusterRole", Name: "cr", Group: "other.group.io"}, + }, + wantFound: false, + wantErr: true, + errContains: []string{"not found"}, + }, + { + name: "wrong_version_rejected", + files: []string{"ClusterRole_rbac.authorization.k8s.io_v1_clusterscoped_cr.yaml"}, + resources: []ResourceMatch{ + {Kind: "ClusterRole", Name: "cr", Group: "rbac.authorization.k8s.io", Version: "v2"}, + }, + wantFound: false, + wantErr: true, + errContains: []string{"not found"}, + }, } for _, tc := range cases { @@ -1868,7 +1888,7 @@ func TestAssertResourcesExist(t *testing.T) { } t.Run("missing_directory", func(t *testing.T) { - _, err := AssertResourcesExist("/nonexistent-dir-for-test", []ResourceMatch{ + _, err := AssertResourcesExist(filepath.Join(t.TempDir(), "does-not-exist"), []ResourceMatch{ {Kind: "ClusterRole", Name: "cr"}, }) if err == nil {