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..a33d2718 --- /dev/null +++ b/e2e-tests/tests/tier0/mta_871_multiple_crbs_split_apply_test.go @@ -0,0 +1,145 @@ +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{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.OutputDir, "resources", "_cluster"), clusterResourcesMatch) + Expect(err).NotTo(HaveOccurred()) + Expect(allPresented).To(BeTrue()) + + By("Cluster admin phase: Applying cluster resources to target as cluster-admin") + Expect(kubectlTgt.ApplyDir(filepath.Join(paths.OutputDir, "resources", "_cluster"))).NotTo(HaveOccurred()) + 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/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..836fd91e --- /dev/null +++ b/e2e-tests/tests/tier0/mta_872_crd_split_apply_test.go @@ -0,0 +1,150 @@ +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", + } + tgtNameSpace := Namespace{Name: namespace} + paths, err := NewScenarioPaths("crane-*") + Expect(err).NotTo(HaveOccurred()) + + runner := scenario.Crane + + exportOpts := ExportOptions{Namespace: srcAppNonAdmin.Namespace, ExportDir: paths.ExportDir} + transformOpts := TransformOptions{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") + kubectlSrcNonAdmin, kubectlTgtNonAdmin, rbacCleanup, err := SetupActiveKubectlRunners(scenario, namespace) + Expect(err).NotTo(HaveOccurred()) + + DeferCleanup(rbacCleanup) + + 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, 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(tgtNameSpace.Create(kubectlTgt)).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") + Expect(cr.AssertField(kubectlTgtNonAdmin, "{.spec.color}", "blue")).NotTo(HaveOccurred()) + + 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..434ecbb3 --- /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{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..a41ec8db 100644 --- a/e2e-tests/utils/utils.go +++ b/e2e-tests/utils/utils.go @@ -1358,6 +1358,73 @@ func ParseValidationReport(validateDir string, outputFormat string, report inter return fmt.Errorf("failed to parse JSON report: %w", err) } } - return 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 { + 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( + "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 +} diff --git a/e2e-tests/utils/utils_test.go b/e2e-tests/utils/utils_test.go index b4e19c0d..02e8bfae 100644 --- a/e2e-tests/utils/utils_test.go +++ b/e2e-tests/utils/utils_test.go @@ -1667,3 +1667,232 @@ 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, + }, + { + 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 { + 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(filepath.Join(t.TempDir(), "does-not-exist"), []ResourceMatch{ + {Kind: "ClusterRole", Name: "cr"}, + }) + if err == nil { + t.Fatal("expected error for missing directory, got nil") + } + }) +}