diff --git a/e2e-tests/framework/resources.go b/e2e-tests/framework/resources.go index fb8d3e37..0737cd3a 100644 --- a/e2e-tests/framework/resources.go +++ b/e2e-tests/framework/resources.go @@ -124,6 +124,7 @@ func (crb ClusterRoleBinding) AddSubject(k KubectlRunner, sa ServiceAccount) err type ServiceAccount struct { Name string Namespace string + Label string } func (sa ServiceAccount) Create(k KubectlRunner) error { @@ -132,6 +133,13 @@ func (sa ServiceAccount) Create(k KubectlRunner) error { return fmt.Errorf("failed to create ServiceAccount %s in %s: %w", sa.Name, sa.Namespace, err) } log.Printf("created ServiceAccount %s in %s", sa.Name, sa.Namespace) + if sa.Label != "" { + _, err = k.Run("label", "serviceaccount", sa.Name, "-n", sa.Namespace, sa.Label) + if err != nil { + return fmt.Errorf("failed to label ServiceAccount %q in namespace %q with label %q: %w", + sa.Name, sa.Namespace, sa.Label, err) + } + } return nil } diff --git a/e2e-tests/testdata/gadget_cr.yaml b/e2e-tests/testdata/gadget_cr.yaml new file mode 100644 index 00000000..dd677444 --- /dev/null +++ b/e2e-tests/testdata/gadget_cr.yaml @@ -0,0 +1,7 @@ +apiVersion: crane-e2e.openshift.io/v1 +kind: Gadget +metadata: + name: test-gadget +spec: + color: red + size: 3 diff --git a/e2e-tests/testdata/gadget_crd.yaml b/e2e-tests/testdata/gadget_crd.yaml new file mode 100644 index 00000000..ac9b4f2c --- /dev/null +++ b/e2e-tests/testdata/gadget_crd.yaml @@ -0,0 +1,27 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: gadgets.crane-e2e.openshift.io +spec: + group: crane-e2e.openshift.io + names: + kind: Gadget + listKind: GadgetList + plural: gadgets + singular: gadget + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + color: + type: string + size: + type: integer diff --git a/e2e-tests/tests/tier0/mta_868_label_scoped_export_test.go b/e2e-tests/tests/tier0/mta_868_label_scoped_export_test.go new file mode 100644 index 00000000..a546b03b --- /dev/null +++ b/e2e-tests/tests/tier0/mta_868_label_scoped_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("Cluster-level export filtering", func() { + It("[CA-8] Should export only labeled workload and its RBAC with --label-selector", 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, + ) + srcApp := scenario.SrcApp + tgtApp := scenario.TgtApp + kubectlSrc := scenario.KubectlSrc + kubectlTgt := scenario.KubectlTgt + runner := scenario.Crane + paths, err := NewScenarioPaths("crane-ca8-*") + Expect(err).NotTo(HaveOccurred()) + + exportOpts := ExportOptions{Namespace: srcApp.Namespace, ExportDir: paths.ExportDir, + LabelSelector: "app=" + appName} + transformOpts := TransformOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir} + applyOpts := ApplyOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir, + OutputDir: paths.OutputDir} + + inScopeSA := ServiceAccount{Name: "nginx-sa", Namespace: namespace, Label: "app=simple-nginx-nopv"} + outOfScopeSA := ServiceAccount{Name: "out-of-scope-sa", Namespace: namespace, Label: "app=outScopedApp"} + + inScopeCR := ClusterRole{Name: "in-scope-cr", Verb: "get,list,watch", Resource: "pods", Label: "app=" + appName} + outOfScopeCR := ClusterRole{Name: "out-scope-cr", Verb: "get,list,watch,create,update,delete", Resource: "pods", Label: "app=outScopedApp"} + + inScopeSubject := "--serviceaccount=" + namespace + ":" + inScopeSA.Name + outScopeSubject := "--serviceaccount=" + namespace + ":" + outOfScopeSA.Name + + inScopeBinding := ClusterRoleBinding{Name: "in-scope-crb", ClusterRoleName: inScopeCR.Name, Subject: inScopeSubject, Label: "app=" + appName} + outOfScopeBinding := ClusterRoleBinding{Name: "out-scope-crb", ClusterRoleName: outOfScopeCR.Name, Subject: outScopeSubject, Label: "app=outScopedApp"} + + outOfScopeResources := []utils.ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: outOfScopeBinding.Name}, + {Kind: "ClusterRole", Name: outOfScopeCR.Name}, + } + inScopeResources := []utils.ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: inScopeBinding.Name}, + {Kind: "ClusterRole", Name: inScopeCR.Name}, + } + DeferCleanup(func() { + if err := ResourceCleanup([]KubectlRunner{kubectlSrc, kubectlTgt}, []Resource{ + inScopeBinding, outOfScopeBinding, inScopeCR, outOfScopeCR, inScopeSA, outOfScopeSA}); err != nil { + log.Printf("Resources cleanup: %v", err) + } + if err := CleanupScenario(paths.TempDir, srcApp, tgtApp); err != nil { + log.Printf("Scenario cleanup: %v", err) + } + }) + + By("Deploying app on source cluster") + Expect(PrepareSourceApp(srcApp, kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating in-scope ServiceAccount with matching label") + Expect(inScopeSA.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating out-of-scope ServiceAccount with different label") + Expect(outOfScopeSA.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating in-scope ClusterRole with matching label") + Expect(inScopeCR.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating out-of-scope ClusterRole with different label") + Expect(outOfScopeCR.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating in-scope ClusterRoleBinding with matching label") + Expect(inScopeBinding.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating out-of-scope ClusterRoleBinding with different label") + Expect(outOfScopeBinding.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Waiting for source pods and endpoints to drain") + WaitForSourceQuiesce(kubectlSrc, namespace, "app="+appName, serviceName) + + By("Running crane export with label-selector, transform, apply") + Expect(RunCranePipelineWithChecks(runner, exportOpts, transformOpts, applyOpts)).NotTo(HaveOccurred()) + + By("Verifying out-of-scope resources are not in export _cluster directory") + exportClusterPath := filepath.Join(paths.ExportDir, "resources", namespace, "_cluster") + allExcluded, err := utils.AssertResourcesDontExist(exportClusterPath, outOfScopeResources) + Expect(err).NotTo(HaveOccurred()) + Expect(allExcluded).To(BeTrue()) + + By("Verifying in-scope ClusterRole and ClusterRoleBinding exist in export, transform, and output") + allFound, err := utils.AssertResourcesExist(exportClusterPath, inScopeResources) + Expect(err).NotTo(HaveOccurred()) + Expect(allFound).To(BeTrue()) + }) +}) diff --git a/e2e-tests/tests/tier0/mta_869_crd_flags_test.go b/e2e-tests/tests/tier0/mta_869_crd_flags_test.go new file mode 100644 index 00000000..ed77b63b --- /dev/null +++ b/e2e-tests/tests/tier0/mta_869_crd_flags_test.go @@ -0,0 +1,217 @@ +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("CRD group filtering during export", func() { + appName := "simple-nginx-nopv" + namespace := "simple-nginx-nopv" + serviceName := "my-" + appName + It("[CA-10a] Should skip CRD when --crd-skip-group matches", Label("tier0"), func() { + scenario := NewMigrationScenario( + appName, + namespace, + config.K8sDeployBin, + config.CraneBin, + config.SourceContext, + config.TargetContext, + ) + srcApp := scenario.SrcApp + tgtApp := scenario.TgtApp + kubectlSrc := scenario.KubectlSrc + kubectlTgt := scenario.KubectlTgt + runner := scenario.Crane + + paths, err := NewScenarioPaths("crane-ca10a-*") + Expect(err).NotTo(HaveOccurred()) + 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", + } + excludedResource := []utils.ResourceMatch{ + {Kind: "CustomResourceDefinition", Name: crd.Name}, + } + includedResource := []utils.ResourceMatch{ + {Kind: cr.Kind, Name: cr.Name, Scope: namespace}, + } + exportOpts := ExportOptions{Namespace: srcApp.Namespace, ExportDir: paths.ExportDir, + CRDSkipGroups: []string{"crane-e2e.example.com"}} + transformOpts := TransformOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir} + applyOpts := ApplyOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir, + OutputDir: paths.OutputDir} + + DeferCleanup(func() { + if err := ResourceCleanup([]KubectlRunner{kubectlSrc, kubectlTgt}, []Resource{cr, crd}); err != nil { + log.Printf("Resources cleanup: %v", err) + } + if err := CleanupScenario(paths.TempDir, srcApp, tgtApp); err != nil { + log.Printf("Scenario cleanup: %v", err) + } + }) + + By("Deploying app on source cluster") + Expect(PrepareSourceApp(srcApp, kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating Widget CRD on source") + Expect(crd.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Waiting for CRD to be established") + Expect(crd.WaitForEstablished(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating Widget custom resource in namespace") + 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 with --crd-skip-group, transform, apply") + Expect(RunCranePipelineWithChecks(runner, exportOpts, transformOpts, applyOpts)).NotTo(HaveOccurred()) + + By("Verifying CRD is excluded from export") + found, err := utils.AssertResourcesExist(paths.ExportDir, excludedResource) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeFalse()) + + By("Verifying Widget CR exists in namespace export directory") + nameSpaceDir := filepath.Join(paths.ExportDir, "resources", namespace) + found, err = utils.AssertResourcesExist(nameSpaceDir, includedResource) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + }) + + It("[CA-10b] Should include CRD when --crd-include-group matches", Label("tier0"), func() { + scenario := NewMigrationScenario( + appName, + namespace, + config.K8sDeployBin, + config.CraneBin, + config.SourceContext, + config.TargetContext, + ) + srcApp := scenario.SrcApp + tgtApp := scenario.TgtApp + kubectlSrc := scenario.KubectlSrc + kubectlTgt := scenario.KubectlTgt + runner := scenario.Crane + + paths, err := NewScenarioPaths("crane-ca10b-*") + Expect(err).NotTo(HaveOccurred()) + crdYAML, err := utils.ReadTestdataFile("gadget_crd.yaml") + Expect(err).NotTo(HaveOccurred()) + crYAML, err := utils.ReadTestdataFile("gadget_cr.yaml") + Expect(err).NotTo(HaveOccurred()) + + crd := CustomResourceDefinition{ + Name: "gadgets.crane-e2e.openshift.io", + YAML: crdYAML, + } + cr := CustomResource{ + Name: "test-gadget", + Namespace: namespace, + Kind: "Gadget", + YAML: crYAML, + Resource: "gadgets", + } + tgtNameSpace := Namespace{Name: namespace} + + exportOpts := ExportOptions{Namespace: srcApp.Namespace, ExportDir: paths.ExportDir, + CRDIncludeGroups: []string{"crane-e2e.openshift.io"}} + transformOpts := TransformOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir} + applyOpts := ApplyOptions{ExportDir: paths.ExportDir, TransformDir: paths.TransformDir, + OutputDir: paths.OutputDir} + + DeferCleanup(func() { + if err := ResourceCleanup([]KubectlRunner{kubectlSrc, kubectlTgt}, []Resource{cr, crd, tgtNameSpace}); err != nil { + log.Printf("Resources cleanup: %v", err) + } + if err := CleanupScenario(paths.TempDir, srcApp, tgtApp); err != nil { + log.Printf("Scenario cleanup: %v", err) + } + }) + + By("Deploying app on source cluster") + Expect(PrepareSourceApp(srcApp, kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating Gadget CRD on source") + Expect(crd.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Waiting for CRD to be established") + Expect(crd.WaitForEstablished(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating Gadget custom resource in namespace") + 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 with --crd-include-group, transform, apply") + Expect(RunCranePipelineWithChecks(runner, exportOpts, transformOpts, applyOpts)).NotTo(HaveOccurred()) + + By("Verifying CRD exists in export _cluster directory") + exportClusterPath := filepath.Join(paths.ExportDir, "resources", namespace, "_cluster") + found, err := utils.AssertResourcesExist(exportClusterPath, []utils.ResourceMatch{ + {Kind: "CustomResourceDefinition", Name: crd.Name}}) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + + By("Verifying Gadget CR exists in namespace export directory") + namespaceDir := filepath.Join(paths.ExportDir, "resources", namespace) + found, err = utils.AssertResourcesExist(namespaceDir, []utils.ResourceMatch{ + {Kind: cr.Kind, Name: cr.Name, Scope: namespace}}) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + + // By("Verifying CRD exists in output _cluster directory") + // outputClusterPath := filepath.Join(paths.OutputDir, "resources", "_cluster") + // Expect(ValidateDirResources(outputClusterPath, crdPatterns)).NotTo(HaveOccurred()) + + By("Creating namespace on target cluster") + Expect(tgtNameSpace.Create(kubectlTgt)).NotTo(HaveOccurred()) + + By("Applying cluster resources to target") + 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("Applying namespace resources to target") + Expect(kubectlTgt.ApplyDir(filepath.Join(paths.OutputDir, "resources", namespace))).NotTo(HaveOccurred()) + + By("Verifying Gadget CR exists on target") + _, err = kubectlTgt.Run("get", "gadget", cr.Name, "-n", namespace) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Gadget CR has correct spec values on target") + color, err := kubectlTgt.Run("get", "gadget", cr.Name, "-n", namespace, + "-o", "jsonpath={.spec.color}") + Expect(err).NotTo(HaveOccurred()) + Expect(color).To(Equal("red")) + + By("Scaling target deployment and validating app") + Expect(kubectlTgt.ScaleDeployment(namespace, appName, 1)).NotTo(HaveOccurred()) + Eventually(tgtApp.Validate, "5m", "10s").Should(Succeed()) + + }) + +}) diff --git a/e2e-tests/tests/tier1/mta_870_unrelated_crb_test.go b/e2e-tests/tests/tier1/mta_870_unrelated_crb_test.go new file mode 100644 index 00000000..d7c274c2 --- /dev/null +++ b/e2e-tests/tests/tier1/mta_870_unrelated_crb_test.go @@ -0,0 +1,104 @@ +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("Cluster-level export filtering", func() { + It("[CA-7] Should not export CRB with subject from another namespace", Label("cluster-admin"), func() { + appName := "simple-nginx-nopv" + namespace := "simple-nginx-nopv" + serviceName := "my-" + appName + scenario := NewMigrationScenario( + appName, + namespace, + config.K8sDeployBin, + config.CraneBin, + config.SourceContext, + config.TargetContext, + ) + srcApp := scenario.SrcApp + tgtApp := scenario.TgtApp + kubectlSrc := scenario.KubectlSrc + kubectlTgt := scenario.KubectlTgt + runner := scenario.Crane + paths, err := NewScenarioPaths("crane-ca7-*") + Expect(err).NotTo(HaveOccurred()) + + exportOpts := ExportOptions{Namespace: srcApp.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-cr", Verb: "get,list,watch", Resource: "pods", Label: "app=" + appName} + unrelatedNamespace := Namespace{Name: "unrelated-name-space"} + + unrelatedSA := ServiceAccount{Name: "unrelated-nginx-sa", Namespace: unrelatedNamespace.Name} + unrelatedSubject := "--serviceaccount=" + unrelatedNamespace.Name + ":" + unrelatedSA.Name + unrelatedCRB := ClusterRoleBinding{Name: "unrelated-crb", ClusterRoleName: cr.Name, Subject: unrelatedSubject} + + relatedSa := ServiceAccount{Name: "nginx-sa", Namespace: namespace} + testSubject := "--serviceaccount=" + namespace + ":" + relatedSa.Name + testCRB := ClusterRoleBinding{Name: "test-crb", ClusterRoleName: cr.Name, Subject: testSubject} + + DeferCleanup(func() { + if err := ResourceCleanup([]KubectlRunner{kubectlSrc, kubectlTgt}, []Resource{ + cr, unrelatedSA, unrelatedCRB, relatedSa, testCRB, unrelatedNamespace}); err != nil { + log.Printf("Resources cleanup: %v", err) + } + if err := CleanupScenario(paths.TempDir, srcApp, tgtApp); err != nil { + log.Printf("Scenario cleanup: %v", err) + } + }) + + By("Deploying app with ServiceAccount on source cluster") + Expect(PrepareSourceApp(srcApp, kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating ClusterRole on source") + Expect(cr.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating unrelated namespace on source") + Expect(unrelatedNamespace.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating ServiceAccount in unrelated namespace") + Expect(unrelatedSA.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating ClusterRoleBinding referencing foreign namespace ServiceAccount") + Expect(unrelatedCRB.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating related ServiceAccount in app namespace") + Expect(relatedSa.Create(kubectlSrc)).NotTo(HaveOccurred()) + + By("Creating ClusterRoleBinding referencing app's ServiceAccount") + Expect(testCRB.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") + Expect(RunCranePipelineWithChecks(runner, exportOpts, transformOpts, applyOpts)).NotTo(HaveOccurred()) + + By("Verifying out-of-scope resources are not in export _cluster directory") + exportClusterPath := filepath.Join(paths.ExportDir, "resources", namespace, "_cluster") + found, err := utils.AssertResourcesExist(exportClusterPath, []utils.ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: unrelatedCRB.Name}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeFalse()) + + By("Verifying linked ClusterRole and ClusterRoleBinding exist in export, transform, and output") + found, err = utils.AssertResourcesExist(exportClusterPath, []utils.ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: testCRB.Name}, + {Kind: "ClusterRole", Name: cr.Name}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + }) +}) diff --git a/e2e-tests/utils/utils.go b/e2e-tests/utils/utils.go index 905b9ba0..e01372d4 100644 --- a/e2e-tests/utils/utils.go +++ b/e2e-tests/utils/utils.go @@ -1191,32 +1191,116 @@ func AssertFilesExist(dir string, expectedFiles []string) error { } 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 - } \ No newline at end of file + +// 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, nil + } + } + 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, nil + } + } + } + return true, nil +} diff --git a/e2e-tests/utils/utils_test.go b/e2e-tests/utils/utils_test.go index 8628c3a0..e7ab70d1 100644 --- a/e2e-tests/utils/utils_test.go +++ b/e2e-tests/utils/utils_test.go @@ -1458,3 +1458,381 @@ func TestNormalizeUnstableFields(t *testing.T) { }) } } + +func TestAssertResourcesExist(t *testing.T) { + // Helper to create dummy cluster resource files in a temp directory + createClusterResourceFiles := func(t *testing.T, dir string, files []string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, f := range files { + path := filepath.Join(dir, f) + if err := os.WriteFile(path, []byte("dummy"), 0o644); err != nil { + t.Fatal(err) + } + } + } + + cases := []struct { + name string + files []string + resources []ResourceMatch + wantFound bool + wantErr bool + }{ + { + name: "finds_single_cluster_role_binding", + files: []string{ + "ClusterRoleBinding_rbac.authorization.k8s.io_v1_clusterscoped_my-crb.yaml", + }, + resources: []ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: "my-crb"}, + }, + wantFound: true, + }, + { + name: "finds_cluster_role_with_group", + files: []string{ + "ClusterRole_rbac.authorization.k8s.io_v1_clusterscoped_my-role.yaml", + }, + resources: []ResourceMatch{ + {Kind: "ClusterRole", Name: "my-role", Group: "rbac.authorization.k8s.io"}, + }, + wantFound: true, + }, + { + name: "finds_cluster_role_with_group_and_version", + files: []string{ + "ClusterRole_rbac.authorization.k8s.io_v1_clusterscoped_my-role.yaml", + }, + resources: []ResourceMatch{ + {Kind: "ClusterRole", Name: "my-role", Group: "rbac.authorization.k8s.io", Version: "v1"}, + }, + wantFound: true, + }, + { + name: "finds_multiple_resources", + files: []string{ + "ClusterRoleBinding_rbac.authorization.k8s.io_v1_clusterscoped_crb-one.yaml", + "ClusterRole_rbac.authorization.k8s.io_v1_clusterscoped_role-one.yaml", + }, + resources: []ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: "crb-one"}, + {Kind: "ClusterRole", Name: "role-one"}, + }, + wantFound: true, + }, + { + name: "returns_false_when_resource_not_found", + files: []string{ + "ClusterRoleBinding_rbac.authorization.k8s.io_v1_clusterscoped_other-crb.yaml", + }, + resources: []ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: "my-crb"}, + }, + wantFound: false, + }, + { + name: "returns_false_when_one_of_multiple_not_found", + files: []string{ + "ClusterRoleBinding_rbac.authorization.k8s.io_v1_clusterscoped_crb-one.yaml", + }, + resources: []ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: "crb-one"}, + {Kind: "ClusterRole", Name: "role-missing"}, + }, + wantFound: false, + }, + { + name: "returns_false_for_empty_directory", + files: []string{}, + resources: []ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: "my-crb"}, + }, + wantFound: false, + }, + { + name: "does_not_match_partial_name", + files: []string{ + "ClusterRoleBinding_rbac.authorization.k8s.io_v1_clusterscoped_other-my-crb.yaml", + }, + resources: []ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: "my-crb"}, + }, + wantFound: false, + }, + { + name: "finds_namespace_scoped_resource", + files: []string{ + "Widget_crane-e2e.example.com_v1_myns_test-widget.yaml", + }, + resources: []ResourceMatch{ + {Kind: "Widget", Name: "test-widget", Scope: "myns"}, + }, + wantFound: true, + }, + { + name: "finds_namespace_scoped_with_group_and_version", + files: []string{ + "Deployment_apps_v1_default_my-deploy.yaml", + }, + resources: []ResourceMatch{ + {Kind: "Deployment", Name: "my-deploy", Scope: "default", Group: "apps", Version: "v1"}, + }, + wantFound: true, + }, + { + name: "does_not_match_wrong_namespace", + files: []string{ + "Widget_crane-e2e.example.com_v1_other-ns_test-widget.yaml", + }, + resources: []ResourceMatch{ + {Kind: "Widget", Name: "test-widget", Scope: "myns"}, + }, + wantFound: false, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + createClusterResourceFiles(t, dir, tc.files) + + found, err := AssertResourcesExist(dir, tc.resources) + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("AssertResourcesExist: %v", err) + } + if found != tc.wantFound { + t.Fatalf("AssertResourcesExist = %v, want %v", found, tc.wantFound) + } + }) + } +} + +func TestAssertResourcesDontExist(t *testing.T) { + createResourceFiles := func(t *testing.T, dir string, files []string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, f := range files { + path := filepath.Join(dir, f) + if err := os.WriteFile(path, []byte("dummy"), 0o644); err != nil { + t.Fatal(err) + } + } + } + + cases := []struct { + name string + files []string + resources []ResourceMatch + wantExcluded bool + wantErr bool + }{ + { + name: "returns_true_for_empty_directory", + files: []string{}, + resources: []ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: "my-crb"}, + }, + wantExcluded: true, + }, + { + name: "returns_true_when_resource_not_found", + files: []string{ + "ClusterRoleBinding_rbac.authorization.k8s.io_v1_clusterscoped_other-crb.yaml", + }, + resources: []ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: "my-crb"}, + }, + wantExcluded: true, + }, + { + name: "returns_false_when_resource_exists", + files: []string{ + "ClusterRoleBinding_rbac.authorization.k8s.io_v1_clusterscoped_my-crb.yaml", + }, + resources: []ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: "my-crb"}, + }, + wantExcluded: false, + }, + { + name: "returns_false_when_any_resource_exists", + files: []string{ + "ClusterRoleBinding_rbac.authorization.k8s.io_v1_clusterscoped_crb-one.yaml", + }, + resources: []ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: "crb-one"}, + {Kind: "ClusterRole", Name: "role-missing"}, + }, + wantExcluded: false, + }, + { + name: "returns_true_when_none_of_multiple_exist", + files: []string{ + "ClusterRoleBinding_rbac.authorization.k8s.io_v1_clusterscoped_other-crb.yaml", + "ClusterRole_rbac.authorization.k8s.io_v1_clusterscoped_other-role.yaml", + }, + resources: []ResourceMatch{ + {Kind: "ClusterRoleBinding", Name: "my-crb"}, + {Kind: "ClusterRole", Name: "my-role"}, + }, + wantExcluded: true, + }, + { + name: "returns_true_for_namespace_scoped_not_found", + files: []string{ + "Widget_crane-e2e.example.com_v1_other-ns_test-widget.yaml", + }, + resources: []ResourceMatch{ + {Kind: "Widget", Name: "test-widget", Scope: "myns"}, + }, + wantExcluded: true, + }, + { + name: "returns_false_for_namespace_scoped_found", + files: []string{ + "Widget_crane-e2e.example.com_v1_myns_test-widget.yaml", + }, + resources: []ResourceMatch{ + {Kind: "Widget", Name: "test-widget", Scope: "myns"}, + }, + wantExcluded: false, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + createResourceFiles(t, dir, tc.files) + + excluded, err := AssertResourcesDontExist(dir, tc.resources) + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("AssertResourcesDontExist: %v", err) + } + if excluded != tc.wantExcluded { + t.Fatalf("AssertResourcesDontExist = %v, want %v", excluded, tc.wantExcluded) + } + }) + } +} + +func TestGetPrefixAndSuffix(t *testing.T) { + cases := []struct { + name string + resource ResourceMatch + wantPrefix string + wantSuffix string + }{ + { + name: "kind_only_defaults_to_clusterscoped", + resource: ResourceMatch{Kind: "ClusterRole", Name: "my-role"}, + wantPrefix: "ClusterRole_", + wantSuffix: "_clusterscoped_my-role.yaml", + }, + { + name: "with_group", + resource: ResourceMatch{Kind: "ClusterRole", Name: "my-role", Group: "rbac.authorization.k8s.io"}, + wantPrefix: "ClusterRole_rbac.authorization.k8s.io_", + wantSuffix: "_clusterscoped_my-role.yaml", + }, + { + name: "with_group_and_version", + resource: ResourceMatch{Kind: "ClusterRole", Name: "my-role", Group: "rbac.authorization.k8s.io", Version: "v1"}, + wantPrefix: "ClusterRole_rbac.authorization.k8s.io_", + wantSuffix: "v1_clusterscoped_my-role.yaml", + }, + { + name: "with_namespace_scope", + resource: ResourceMatch{Kind: "Widget", Name: "test-widget", Scope: "myns"}, + wantPrefix: "Widget_", + wantSuffix: "_myns_test-widget.yaml", + }, + { + name: "full_specification", + resource: ResourceMatch{Kind: "Deployment", Name: "my-app", Group: "apps", Version: "v1", Scope: "default"}, + wantPrefix: "Deployment_apps_", + wantSuffix: "v1_default_my-app.yaml", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + prefix, suffix := getPrefixAndSuffix(tc.resource) + if prefix != tc.wantPrefix { + t.Fatalf("getPrefixAndSuffix prefix = %q, want %q", prefix, tc.wantPrefix) + } + if suffix != tc.wantSuffix { + t.Fatalf("getPrefixAndSuffix 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: "matches_both", + file: "ClusterRole_rbac.authorization.k8s.io_v1_clusterscoped_my-role.yaml", + prefix: "ClusterRole_", + suffix: "v1_clusterscoped_my-role.yaml", + want: true, + }, + { + name: "prefix_mismatch", + file: "ClusterRoleBinding_rbac.authorization.k8s.io_v1_clusterscoped_my-role.yaml", + prefix: "ClusterRole_", + suffix: "v1_clusterscoped_my-role.yaml", + want: false, + }, + { + name: "suffix_mismatch", + file: "ClusterRole_rbac.authorization.k8s.io_v1_clusterscoped_other-role.yaml", + prefix: "ClusterRole_", + suffix: "v1_clusterscoped_my-role.yaml", + want: false, + }, + { + name: "both_mismatch", + file: "Widget_example.com_v1_ns_widget.yaml", + prefix: "ClusterRole_", + suffix: "_clusterscoped_my-role.yaml", + want: false, + }, + } + + for _, tc := range cases { + tc := tc + 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) + } + }) + } +}