diff --git a/cmd/ocm-backplane/testJob/createTestJob.go b/cmd/ocm-backplane/testJob/createTestJob.go index d8340a2c..d31a030d 100644 --- a/cmd/ocm-backplane/testJob/createTestJob.go +++ b/cmd/ocm-backplane/testJob/createTestJob.go @@ -51,6 +51,7 @@ Example usage: cd scripts/SREP/example && ocm backplane testjob create -p var1=val1 `, + Deprecated: "use 'ocm backplane testjob render' to generate YAML and apply it directly with 'oc apply -f'", SilenceUsage: true, SilenceErrors: true, RunE: runCreateTestJob, diff --git a/cmd/ocm-backplane/testJob/getTestJob.go b/cmd/ocm-backplane/testJob/getTestJob.go index 67a1b527..d19244d4 100644 --- a/cmd/ocm-backplane/testJob/getTestJob.go +++ b/cmd/ocm-backplane/testJob/getTestJob.go @@ -20,6 +20,7 @@ func newGetTestJobCommand() *cobra.Command { cmd := &cobra.Command{ Use: "get ", Short: "Get a backplane testjob resource", + Deprecated: "use 'ocm backplane testjob render' to generate YAML and apply it directly with 'oc apply -f'", Args: cobra.ExactArgs(1), SilenceUsage: true, SilenceErrors: true, diff --git a/cmd/ocm-backplane/testJob/getTestJobLogs.go b/cmd/ocm-backplane/testJob/getTestJobLogs.go index f92e0e3d..6e0657f1 100644 --- a/cmd/ocm-backplane/testJob/getTestJobLogs.go +++ b/cmd/ocm-backplane/testJob/getTestJobLogs.go @@ -23,6 +23,7 @@ func newGetTestJobLogsCommand() *cobra.Command { Use: "logs ", Aliases: []string{"log"}, Short: "Get a backplane testJob logs", + Deprecated: "use 'ocm backplane testjob render' to generate YAML and apply it directly with 'oc apply -f'", Args: cobra.ExactArgs(1), SilenceUsage: true, SilenceErrors: true, diff --git a/cmd/ocm-backplane/testJob/renderTestJob.go b/cmd/ocm-backplane/testJob/renderTestJob.go new file mode 100644 index 00000000..d3402792 --- /dev/null +++ b/cmd/ocm-backplane/testJob/renderTestJob.go @@ -0,0 +1,536 @@ +package testjob + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" + + backplaneApi "github.com/openshift/backplane-api/pkg/client" + "github.com/openshift/backplane-cli/pkg/utils" +) + +const ( + backplaneJobsNamespace = "openshift-backplane-managed-scripts" + generateNamePrefixForTestScript = "openshift-job-dev-" + baseImageRegistry = "quay.io/redhat-user-workloads/rosa-tenant/managed-scripts" + managedScriptsCommitAPI = "https://api.github.com/repos/openshift/managed-scripts/commits/main" +) + +func newRenderTestJobCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "render", + Short: "Render Kubernetes YAML for a test script (client-side, no API call)", + Long: ` +Render the Kubernetes objects needed to run a managed script on a cluster. + +This command generates YAML for ServiceAccount, RBAC, and Pod resources +that you can apply directly with 'oc apply -f' on a cluster where you +have cluster-admin access. + +No backplane API call is made — everything is generated locally. + +Example usage: + cd scripts/SREP/example + ocm backplane testjob render -p VAR1=val1 > test-job.yaml + oc apply -f test-job.yaml + +To clean up after testing: + oc delete -f test-job.yaml +`, + SilenceUsage: true, + SilenceErrors: true, + RunE: runRenderTestJob, + } + + cmd.Flags().StringArrayP( + "params", + "p", + []string{}, + "Params to be passed to the script. Example: -p 'VAR1=VAL1' -p VAR2=VAL2", + ) + + cmd.Flags().StringP( + "source-dir", + "s", + "", + "Optional source dir for the script (defaults to current directory)", + ) + + cmd.Flags().StringP( + "base-image-override", + "i", + "", + "Container image to run the script. Defaults to the latest managed-scripts image resolved from GitHub. Use 'git ls-remote https://github.com/openshift/managed-scripts HEAD | cut -f1' to get a specific tag.", + ) + + cmd.Flags().StringP( + "output", + "o", + "", + "Write output to file instead of stdout", + ) + + return cmd +} + +func runRenderTestJob(cmd *cobra.Command, args []string) error { + arr, err := cmd.Flags().GetStringArray("params") + if err != nil { + return err + } + + parsedParams, err := utils.ParseParamsFlag(arr) + if err != nil { + return err + } + + sourceDirFlag, err := cmd.Flags().GetString("source-dir") + if err != nil { + return err + } + + baseImageOverride, err := cmd.Flags().GetString("base-image-override") + if err != nil { + return err + } + + outputFile, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + + sourceDir := "./" + if sourceDirFlag != "" { + sourceDir = sourceDirFlag + "/" + } + + metadata, scriptBody, err := readScriptFromFiles(sourceDir) + if err != nil { + return err + } + + if err := validateParams(metadata, parsedParams); err != nil { + return err + } + + baseImage := baseImageOverride + if baseImage == "" { + sha, err := resolveBaseImageSHA() + if err != nil { + fmt.Fprintf(os.Stderr, "You can specify the image manually with --base-image-override (-i).\nTo find the latest tag, run:\n git ls-remote https://github.com/openshift/managed-scripts HEAD | cut -f1\n\nThen use it as:\n ocm backplane testjob render -i %s: ...\n", baseImageRegistry) + return fmt.Errorf("failed to resolve managed-scripts image tag: %w", err) + } + baseImage = fmt.Sprintf("%s:%s", baseImageRegistry, sha) + fmt.Fprintf(os.Stderr, "Resolved image: %s\n", baseImage) + } + + yamlOutput, err := renderKubeObjects(metadata, scriptBody, parsedParams, baseImage) + if err != nil { + return err + } + + if outputFile != "" { + return os.WriteFile(outputFile, []byte(yamlOutput), 0600) + } + fmt.Print(yamlOutput) + return nil +} + +func readScriptFromFiles(sourceDir string) (backplaneApi.ScriptMetadata, string, error) { + var metadata backplaneApi.ScriptMetadata + + metaFile := sourceDir + "metadata.yaml" + yamlFile, err := os.ReadFile(metaFile) //nolint:gosec + if err != nil { + return metadata, "", fmt.Errorf("error reading metadata.yaml: %v (ensure you are in a script directory or specify --source-dir)", err) + } + + if err := yaml.Unmarshal(yamlFile, &metadata); err != nil { + return metadata, "", fmt.Errorf("error parsing metadata.yaml: %v", err) + } + + scriptFile := sourceDir + metadata.File + fileBody, err := os.ReadFile(scriptFile) //nolint:gosec + if err != nil { + return metadata, "", fmt.Errorf("unable to read script file %s: %v", scriptFile, err) + } + + fileBodyStr := string(fileBody) + fileBodyStr, err = inlineLibrarySourceFiles(fileBodyStr, scriptFile) + if err != nil { + return metadata, "", err + } + + return metadata, fileBodyStr, nil +} + +func validateParams(metadata backplaneApi.ScriptMetadata, params map[string]string) error { + if metadata.Envs == nil { + if len(params) > 0 { + return fmt.Errorf("script doesn't accept parameters") + } + return nil + } + + for _, env := range metadata.Envs { + if env.Key == nil { + continue + } + if env.Optional != nil && !*env.Optional { + if _, ok := params[*env.Key]; !ok { + return fmt.Errorf("missing required parameter: %s", *env.Key) + } + } + } + + for key := range params { + found := false + for _, env := range metadata.Envs { + if env.Key != nil && *env.Key == key { + found = true + break + } + } + if !found { + return fmt.Errorf("invalid parameter: %s", key) + } + } + + return nil +} + +func renderKubeObjects(metadata backplaneApi.ScriptMetadata, scriptBody string, params map[string]string, baseImage string) (string, error) { + name := fmt.Sprintf("%s%d", generateNamePrefixForTestScript, time.Now().Unix()) + + labels := map[string]string{ + "managed.openshift.io/backplane-job-canonical-namespace": "TEST", + "managed.openshift.io/backplane-job-canonical-script-name": metadata.Name, + "managed.openshift.io/backplane-job-is-test": "true", + "managed.openshift.io/backplane-job-id": name, + } + + var objects []string + + // ServiceAccount + sa := &corev1.ServiceAccount{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "ServiceAccount", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: backplaneJobsNamespace, + Labels: labels, + }, + AutomountServiceAccountToken: ptrBool(true), + } + saYAML, err := yaml.Marshal(sa) + if err != nil { + return "", fmt.Errorf("error marshalling ServiceAccount: %v", err) + } + objects = append(objects, string(saYAML)) + + // Namespaced Roles and RoleBindings. + // Multiple rbac.roles entries can share a namespace; merge their rules so a + // single Role per namespace is emitted (identical Role names in the same + // namespace would otherwise overwrite each other on 'oc apply'). + if metadata.Rbac.Roles != nil { + rulesByNamespace := make(map[string][]rbacv1.PolicyRule) + var namespaceOrder []string + for _, roleDecl := range *metadata.Rbac.Roles { + if roleDecl.Namespace == nil || *roleDecl.Namespace == "" || roleDecl.Rules == nil || len(*roleDecl.Rules) == 0 { + fmt.Fprintf(os.Stderr, "warning: skipping rbac.roles entry with empty namespace or rules\n") + continue + } + ns := *roleDecl.Namespace + if _, seen := rulesByNamespace[ns]; !seen { + namespaceOrder = append(namespaceOrder, ns) + } + rulesByNamespace[ns] = append(rulesByNamespace[ns], convertPolicyRules(*roleDecl.Rules)...) + } + + for _, ns := range namespaceOrder { + role := &rbacv1.Role{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "rbac.authorization.k8s.io/v1", + Kind: "Role", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + Labels: labels, + }, + Rules: rulesByNamespace[ns], + } + roleYAML, err := yaml.Marshal(role) + if err != nil { + return "", fmt.Errorf("error marshalling Role: %v", err) + } + objects = append(objects, string(roleYAML)) + + rb := &rbacv1.RoleBinding{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "rbac.authorization.k8s.io/v1", + Kind: "RoleBinding", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + Labels: labels, + }, + Subjects: []rbacv1.Subject{ + { + Kind: rbacv1.ServiceAccountKind, + Name: name, + Namespace: backplaneJobsNamespace, + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "Role", + Name: name, + }, + } + rbYAML, err := yaml.Marshal(rb) + if err != nil { + return "", fmt.Errorf("error marshalling RoleBinding: %v", err) + } + objects = append(objects, string(rbYAML)) + } + } + + // ClusterRole and ClusterRoleBinding + if metadata.Rbac.ClusterRoleRules != nil && len(*metadata.Rbac.ClusterRoleRules) > 0 { + rules := convertPolicyRules(*metadata.Rbac.ClusterRoleRules) + cr := &rbacv1.ClusterRole{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "rbac.authorization.k8s.io/v1", + Kind: "ClusterRole", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: labels, + }, + Rules: rules, + } + crYAML, err := yaml.Marshal(cr) + if err != nil { + return "", fmt.Errorf("error marshalling ClusterRole: %v", err) + } + objects = append(objects, string(crYAML)) + + crb := &rbacv1.ClusterRoleBinding{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "rbac.authorization.k8s.io/v1", + Kind: "ClusterRoleBinding", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: labels, + }, + Subjects: []rbacv1.Subject{ + { + Kind: rbacv1.ServiceAccountKind, + Name: name, + Namespace: backplaneJobsNamespace, + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "ClusterRole", + Name: name, + }, + } + crbYAML, err := yaml.Marshal(crb) + if err != nil { + return "", fmt.Errorf("error marshalling ClusterRoleBinding: %v", err) + } + objects = append(objects, string(crbYAML)) + } + + // Pod + envVars := buildEnvVars(metadata, params) + podCommand := getPodCommand(metadata.Language, scriptBody) + runAsNonRoot := true + + pod := &corev1.Pod{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Pod", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: backplaneJobsNamespace, + Labels: labels, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: name, + RestartPolicy: corev1.RestartPolicyNever, + SecurityContext: &corev1.PodSecurityContext{ + RunAsNonRoot: &runAsNonRoot, + SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}, + }, + Containers: []corev1.Container{ + { + Name: "job", + Image: baseImage, + Command: podCommand, + Env: envVars, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("10m"), + corev1.ResourceMemory: resource.MustParse("100Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceMemory: resource.MustParse("2048Mi"), + }, + }, + SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: ptrBool(false), + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + }, + RunAsNonRoot: &runAsNonRoot, + SeccompProfile: &corev1.SeccompProfile{ + Type: corev1.SeccompProfileTypeRuntimeDefault, + }, + }, + }, + }, + Affinity: &corev1.Affinity{ + NodeAffinity: &corev1.NodeAffinity{ + PreferredDuringSchedulingIgnoredDuringExecution: []corev1.PreferredSchedulingTerm{ + { + Weight: 100, + Preference: corev1.NodeSelectorTerm{ + MatchExpressions: []corev1.NodeSelectorRequirement{{ + Key: "node-role.kubernetes.io/infra", + Operator: "Exists", + }}, + }, + }, + }, + }, + }, + Tolerations: []corev1.Toleration{{ + Key: "node-role.kubernetes.io/infra", + Operator: "Exists", + Effect: "NoSchedule", + }}, + }, + } + podYAML, err := yaml.Marshal(pod) + if err != nil { + return "", fmt.Errorf("error marshalling Pod: %v", err) + } + objects = append(objects, string(podYAML)) + + return strings.Join(objects, "---\n"), nil +} + +func convertPolicyRules(rules []backplaneApi.PolicyRule) []rbacv1.PolicyRule { + var k8sRules []rbacv1.PolicyRule + for _, r := range rules { + rule := rbacv1.PolicyRule{} + if r.Verbs != nil { + rule.Verbs = *r.Verbs + } + if r.ApiGroups != nil { + rule.APIGroups = *r.ApiGroups + } + if r.Resources != nil { + rule.Resources = *r.Resources + } + if r.ResourceNames != nil { + rule.ResourceNames = *r.ResourceNames + } + if r.NonResourceURLs != nil { + rule.NonResourceURLs = *r.NonResourceURLs + } + k8sRules = append(k8sRules, rule) + } + return k8sRules +} + +func buildEnvVars(metadata backplaneApi.ScriptMetadata, params map[string]string) []corev1.EnvVar { + var envVars []corev1.EnvVar + if metadata.Envs == nil { + return envVars + } + for _, env := range metadata.Envs { + if env.Key == nil { + continue + } + if val, ok := params[*env.Key]; ok { + envVars = append(envVars, corev1.EnvVar{ + Name: *env.Key, + Value: val, + }) + } + } + return envVars +} + +func getPodCommand(language backplaneApi.ScriptMetadataLanguage, scriptBody string) []string { + encoded := base64.StdEncoding.EncodeToString([]byte(scriptBody)) + switch language { + case backplaneApi.ScriptMetadataLanguagePython: + return []string{"/bin/sh", "-c", fmt.Sprintf("echo '%s' | base64 -d | /bin/python3", encoded)} + case backplaneApi.ScriptMetadataLanguageBash: + return []string{"/bin/sh", "-c", fmt.Sprintf("echo '%s' | base64 -d | /bin/bash", encoded)} + default: + return []string{"/bin/sh", "-c", fmt.Sprintf("echo '%s' | base64 -d | /bin/bash", encoded)} + } +} + +func ptrBool(b bool) *bool { + return &b +} + +// resolveBaseImageSHA resolves the managed-scripts image tag. It is a variable +// so tests can stub the network call. +var resolveBaseImageSHA = fetchManagedScriptsHeadSHA + +func fetchManagedScriptsHeadSHA() (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, managedScriptsCommitAPI, nil) + if err != nil { + return "", fmt.Errorf("failed to build request: %v", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("HTTP request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + var result struct { + SHA string `json:"sha"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to parse response: %v", err) + } + if result.SHA == "" { + return "", fmt.Errorf("empty SHA in GitHub API response") + } + return result.SHA, nil +} diff --git a/cmd/ocm-backplane/testJob/renderTestJob_test.go b/cmd/ocm-backplane/testJob/renderTestJob_test.go new file mode 100644 index 00000000..e21535d2 --- /dev/null +++ b/cmd/ocm-backplane/testJob/renderTestJob_test.go @@ -0,0 +1,362 @@ +package testjob + +import ( + "fmt" + "os" + "path" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" +) + +const testImage = "quay.io/test/managed-scripts:abc1234" + +var _ = Describe("testJob render command", func() { + + var ( + tempDir string + sut *cobra.Command + ) + + BeforeEach(func() { + tempDir, _ = os.MkdirTemp("", "renderJobTest") + sut = NewTestJobCommand() + }) + + AfterEach(func() { + _ = os.RemoveAll(tempDir) + }) + + Context("render test job YAML", func() { + It("should render YAML for a simple script with cluster role rules", func() { + _ = os.WriteFile(path.Join(tempDir, "metadata.yaml"), []byte(MetadataYaml), 0600) + _ = os.WriteFile(path.Join(tempDir, "script.sh"), []byte("echo hello"), 0600) + + outputFile := path.Join(tempDir, "output.yaml") + sut.SetArgs([]string{"render", "--source-dir", tempDir, "--output", outputFile, "-i", testImage}) + err := sut.Execute() + + Expect(err).To(BeNil()) + + content, err := os.ReadFile(outputFile) + Expect(err).To(BeNil()) + + yamlStr := string(content) + Expect(yamlStr).To(ContainSubstring("kind: ServiceAccount")) + Expect(yamlStr).To(ContainSubstring("kind: ClusterRole")) + Expect(yamlStr).To(ContainSubstring("kind: ClusterRoleBinding")) + Expect(yamlStr).To(ContainSubstring("kind: Role")) + Expect(yamlStr).To(ContainSubstring("kind: RoleBinding")) + Expect(yamlStr).To(ContainSubstring("kind: Pod")) + Expect(yamlStr).To(ContainSubstring("namespace: openshift-backplane-managed-scripts")) + Expect(yamlStr).To(ContainSubstring("namespace: kube-system")) + Expect(yamlStr).To(ContainSubstring("openshift-job-dev-")) + Expect(yamlStr).To(ContainSubstring("managed.openshift.io/backplane-job-is-test: \"true\"")) + Expect(yamlStr).To(ContainSubstring("image: " + testImage)) + }) + + It("should render YAML for a script with only namespaced roles", func() { + metadata := ` +file: script.sh +name: ns-only +description: namespaced only +author: tester +allowedGroups: + - SREP +rbac: + roles: + - namespace: "openshift-monitoring" + rules: + - verbs: ["get", "list"] + apiGroups: [""] + resources: ["configmaps"] +language: bash +` + _ = os.WriteFile(path.Join(tempDir, "metadata.yaml"), []byte(metadata), 0600) + _ = os.WriteFile(path.Join(tempDir, "script.sh"), []byte("echo hello"), 0600) + + outputFile := path.Join(tempDir, "output.yaml") + sut.SetArgs([]string{"render", "--source-dir", tempDir, "--output", outputFile, "-i", testImage}) + err := sut.Execute() + + Expect(err).To(BeNil()) + + content, err := os.ReadFile(outputFile) + Expect(err).To(BeNil()) + + yamlStr := string(content) + Expect(yamlStr).To(ContainSubstring("kind: ServiceAccount")) + Expect(yamlStr).To(ContainSubstring("kind: Role")) + Expect(yamlStr).To(ContainSubstring("kind: RoleBinding")) + Expect(yamlStr).To(ContainSubstring("namespace: openshift-monitoring")) + Expect(yamlStr).To(ContainSubstring("kind: Pod")) + Expect(yamlStr).NotTo(ContainSubstring("kind: ClusterRole")) + }) + + It("should include env vars from parameters", func() { + metadata := ` +file: script.sh +name: with-params +description: script with params +author: tester +allowedGroups: + - SREP +envs: + - key: MY_VAR + description: "A param" + optional: false +rbac: + roles: [] +language: bash +` + _ = os.WriteFile(path.Join(tempDir, "metadata.yaml"), []byte(metadata), 0600) + _ = os.WriteFile(path.Join(tempDir, "script.sh"), []byte("echo $MY_VAR"), 0600) + + outputFile := path.Join(tempDir, "output.yaml") + sut.SetArgs([]string{"render", "--source-dir", tempDir, "--output", outputFile, "-p", "MY_VAR=hello", "-i", testImage}) + err := sut.Execute() + + Expect(err).To(BeNil()) + + content, err := os.ReadFile(outputFile) + Expect(err).To(BeNil()) + + yamlStr := string(content) + Expect(yamlStr).To(ContainSubstring("name: MY_VAR")) + Expect(yamlStr).To(ContainSubstring("value: hello")) + }) + + It("should auto-resolve image from the resolver when --base-image-override is not provided", func() { + original := resolveBaseImageSHA + resolveBaseImageSHA = func() (string, error) { return "deadbeef", nil } + defer func() { resolveBaseImageSHA = original }() + + metadata := ` +file: script.sh +name: auto-resolve +description: auto resolve image +author: tester +rbac: + roles: [] +language: bash +` + _ = os.WriteFile(path.Join(tempDir, "metadata.yaml"), []byte(metadata), 0600) + _ = os.WriteFile(path.Join(tempDir, "script.sh"), []byte("echo test"), 0600) + + outputFile := path.Join(tempDir, "output.yaml") + sut.SetArgs([]string{"render", "--source-dir", tempDir, "--output", outputFile}) + err := sut.Execute() + + Expect(err).To(BeNil()) + + content, err := os.ReadFile(outputFile) + Expect(err).To(BeNil()) + + yamlStr := string(content) + Expect(yamlStr).To(ContainSubstring("image: " + baseImageRegistry + ":deadbeef")) + }) + + It("should propagate an error when the resolver fails", func() { + original := resolveBaseImageSHA + resolveBaseImageSHA = func() (string, error) { return "", fmt.Errorf("boom") } + defer func() { resolveBaseImageSHA = original }() + + metadata := ` +file: script.sh +name: resolve-fail +description: resolver failure +author: tester +rbac: + roles: [] +language: bash +` + _ = os.WriteFile(path.Join(tempDir, "metadata.yaml"), []byte(metadata), 0600) + _ = os.WriteFile(path.Join(tempDir, "script.sh"), []byte("echo test"), 0600) + + sut.SetArgs([]string{"render", "--source-dir", tempDir}) + err := sut.Execute() + + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to resolve managed-scripts image tag")) + }) + + It("should merge rules from multiple rbac.roles entries sharing a namespace", func() { + metadata := ` +file: script.sh +name: shared-ns +description: shared namespace roles +author: tester +allowedGroups: + - SREP +rbac: + roles: + - namespace: "openshift-monitoring" + rules: + - verbs: ["get"] + apiGroups: [""] + resources: ["configmaps"] + - namespace: "openshift-monitoring" + rules: + - verbs: ["list"] + apiGroups: [""] + resources: ["secrets"] +language: bash +` + _ = os.WriteFile(path.Join(tempDir, "metadata.yaml"), []byte(metadata), 0600) + _ = os.WriteFile(path.Join(tempDir, "script.sh"), []byte("echo hello"), 0600) + + outputFile := path.Join(tempDir, "output.yaml") + sut.SetArgs([]string{"render", "--source-dir", tempDir, "--output", outputFile, "-i", testImage}) + err := sut.Execute() + + Expect(err).To(BeNil()) + + content, err := os.ReadFile(outputFile) + Expect(err).To(BeNil()) + + yamlStr := string(content) + // Only one top-level Role should be emitted for the shared namespace, + // containing both rule sets. Count only document-level "kind: Role" + // lines (no leading indentation) to avoid matching the roleRef inside + // the RoleBinding. + topLevelRoles := 0 + for _, line := range strings.Split(yamlStr, "\n") { + if line == "kind: Role" { + topLevelRoles++ + } + } + Expect(topLevelRoles).To(Equal(1)) + Expect(yamlStr).To(ContainSubstring("configmaps")) + Expect(yamlStr).To(ContainSubstring("secrets")) + }) + + It("should fail when a required parameter is missing", func() { + metadata := ` +file: script.sh +name: needs-param +description: needs a param +author: tester +envs: + - key: REQUIRED_VAR + description: "required" + optional: false +rbac: + roles: [] +language: bash +` + _ = os.WriteFile(path.Join(tempDir, "metadata.yaml"), []byte(metadata), 0600) + _ = os.WriteFile(path.Join(tempDir, "script.sh"), []byte("echo test"), 0600) + + sut.SetArgs([]string{"render", "--source-dir", tempDir, "-i", testImage}) + err := sut.Execute() + + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("missing required parameter")) + }) + + It("should fail when an invalid parameter is provided", func() { + metadata := ` +file: script.sh +name: valid-only +description: valid only +author: tester +envs: + - key: VALID_KEY + description: "valid" + optional: true +rbac: + roles: [] +language: bash +` + _ = os.WriteFile(path.Join(tempDir, "metadata.yaml"), []byte(metadata), 0600) + _ = os.WriteFile(path.Join(tempDir, "script.sh"), []byte("echo test"), 0600) + + sut.SetArgs([]string{"render", "--source-dir", tempDir, "-p", "INVALID_KEY=abc", "-i", testImage}) + err := sut.Execute() + + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("invalid parameter")) + }) + + It("should fail when metadata.yaml is missing", func() { + sut.SetArgs([]string{"render", "--source-dir", tempDir, "-i", testImage}) + err := sut.Execute() + + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("error reading metadata.yaml")) + }) + + It("should fail when script file is missing", func() { + metadata := ` +file: missing.sh +name: missing +description: missing script +author: tester +rbac: + roles: [] +language: bash +` + _ = os.WriteFile(path.Join(tempDir, "metadata.yaml"), []byte(metadata), 0600) + + sut.SetArgs([]string{"render", "--source-dir", tempDir, "-i", testImage}) + err := sut.Execute() + + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("unable to read script file")) + }) + + It("should use the provided base image in the pod spec", func() { + metadata := ` +file: script.sh +name: custom-img +description: custom image +author: tester +rbac: + roles: [] +language: bash +` + _ = os.WriteFile(path.Join(tempDir, "metadata.yaml"), []byte(metadata), 0600) + _ = os.WriteFile(path.Join(tempDir, "script.sh"), []byte("echo test"), 0600) + + outputFile := path.Join(tempDir, "output.yaml") + sut.SetArgs([]string{"render", "--source-dir", tempDir, "--output", outputFile, "-i", "quay.io/custom/image:v1"}) + err := sut.Execute() + + Expect(err).To(BeNil()) + + content, err := os.ReadFile(outputFile) + Expect(err).To(BeNil()) + + yamlStr := string(content) + Expect(yamlStr).To(ContainSubstring("image: quay.io/custom/image:v1")) + }) + + It("should render python command for python scripts", func() { + metadata := ` +file: script.py +name: python-test +description: python script +author: tester +rbac: + roles: [] +language: python +` + _ = os.WriteFile(path.Join(tempDir, "metadata.yaml"), []byte(metadata), 0600) + _ = os.WriteFile(path.Join(tempDir, "script.py"), []byte("print('hello')"), 0600) + + outputFile := path.Join(tempDir, "output.yaml") + sut.SetArgs([]string{"render", "--source-dir", tempDir, "--output", outputFile, "-i", testImage}) + err := sut.Execute() + + Expect(err).To(BeNil()) + + content, err := os.ReadFile(outputFile) + Expect(err).To(BeNil()) + + yamlStr := string(content) + Expect(yamlStr).To(ContainSubstring("/bin/python3")) + }) + }) +}) diff --git a/cmd/ocm-backplane/testJob/testJob.go b/cmd/ocm-backplane/testJob/testJob.go index a63b272d..20187f55 100644 --- a/cmd/ocm-backplane/testJob/testJob.go +++ b/cmd/ocm-backplane/testJob/testJob.go @@ -34,6 +34,7 @@ func NewTestJobCommand() *cobra.Command { newCreateTestJobCommand(), newGetTestJobCommand(), newGetTestJobLogsCommand(), + newRenderTestJobCommand(), ) return cmd