From c036c4ee497a47c3637feecd3adc4c4cd0686cfc Mon Sep 17 00:00:00 2001 From: Siu Wa Wu Date: Tue, 11 Aug 2026 18:31:29 +1000 Subject: [PATCH 1/4] Add a helper to generate yaml to test scripts --- cmd/ocm-backplane/testJob/createTestJob.go | 1 + cmd/ocm-backplane/testJob/getTestJob.go | 1 + cmd/ocm-backplane/testJob/getTestJobLogs.go | 1 + cmd/ocm-backplane/testJob/renderTestJob.go | 478 ++++++++++++++++++ .../testJob/renderTestJob_test.go | 254 ++++++++++ cmd/ocm-backplane/testJob/testJob.go | 1 + 6 files changed, 736 insertions(+) create mode 100644 cmd/ocm-backplane/testJob/renderTestJob.go create mode 100644 cmd/ocm-backplane/testJob/renderTestJob_test.go 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..a1937489 --- /dev/null +++ b/cmd/ocm-backplane/testJob/renderTestJob.go @@ -0,0 +1,478 @@ +package testjob + +import ( + "encoding/base64" + "fmt" + "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-" + defaultBaseImage = "quay.io/redhat-user-workloads/rosa-tenant/managed-scripts:latest" +) + +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", + "", + "Custom container image to use instead of the default managed-scripts image", + ) + + 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 := defaultBaseImage + if baseImageOverride != "" { + baseImage = baseImageOverride + } + + 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 + if metadata.Rbac.Roles != nil { + for _, roleDecl := range *metadata.Rbac.Roles { + if roleDecl.Namespace == nil || *roleDecl.Namespace == "" || roleDecl.Rules == nil || len(*roleDecl.Rules) == 0 { + continue + } + rules := convertPolicyRules(*roleDecl.Rules) + role := &rbacv1.Role{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "rbac.authorization.k8s.io/v1", + Kind: "Role", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: *roleDecl.Namespace, + Labels: labels, + }, + Rules: rules, + } + 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: *roleDecl.Namespace, + 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 +} diff --git a/cmd/ocm-backplane/testJob/renderTestJob_test.go b/cmd/ocm-backplane/testJob/renderTestJob_test.go new file mode 100644 index 00000000..7a1bfea6 --- /dev/null +++ b/cmd/ocm-backplane/testJob/renderTestJob_test.go @@ -0,0 +1,254 @@ +package testjob + +import ( + "os" + "path" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" +) + +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}) + 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\"")) + }) + + 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}) + 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"}) + 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 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}) + 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"}) + 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}) + 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}) + err := sut.Execute() + + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("unable to read script file")) + }) + + It("should use base-image-override when specified", 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, "--base-image-override", "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")) + Expect(yamlStr).NotTo(ContainSubstring(defaultBaseImage)) + }) + + 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}) + 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 From cdb05b63ed7e6dba3f7bb3c37e0994c8174c81a6 Mon Sep 17 00:00:00 2001 From: Siu Wa Wu Date: Thu, 13 Aug 2026 17:20:22 +1000 Subject: [PATCH 2/4] use the latest sha in testjob image --- cmd/ocm-backplane/testJob/renderTestJob.go | 41 +++++++++++++-- .../testJob/renderTestJob_test.go | 50 +++++++++++++++---- 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/cmd/ocm-backplane/testJob/renderTestJob.go b/cmd/ocm-backplane/testJob/renderTestJob.go index a1937489..cf380763 100644 --- a/cmd/ocm-backplane/testJob/renderTestJob.go +++ b/cmd/ocm-backplane/testJob/renderTestJob.go @@ -2,7 +2,9 @@ package testjob import ( "encoding/base64" + "encoding/json" "fmt" + "net/http" "os" "strings" "time" @@ -21,7 +23,8 @@ import ( const ( backplaneJobsNamespace = "openshift-backplane-managed-scripts" generateNamePrefixForTestScript = "openshift-job-dev-" - defaultBaseImage = "quay.io/redhat-user-workloads/rosa-tenant/managed-scripts:latest" + 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 { @@ -68,7 +71,7 @@ To clean up after testing: "base-image-override", "i", "", - "Custom container image to use instead of the default managed-scripts image", + "Container image to run the script (required). Use 'git ls-remote https://github.com/openshift/managed-scripts HEAD | cut -f1' to get the latest tag.", ) cmd.Flags().StringP( @@ -121,9 +124,14 @@ func runRenderTestJob(cmd *cobra.Command, args []string) error { return err } - baseImage := defaultBaseImage - if baseImageOverride != "" { - baseImage = baseImageOverride + baseImage := baseImageOverride + if baseImage == "" { + sha, err := fetchManagedScriptsHeadSHA() + if err != nil { + return fmt.Errorf("failed to resolve managed-scripts image tag: %v\n\nYou 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: ...", err, baseImageRegistry) + } + baseImage = fmt.Sprintf("%s:%s", baseImageRegistry, sha) + fmt.Fprintf(os.Stderr, "Resolved image: %s\n", baseImage) } yamlOutput, err := renderKubeObjects(metadata, scriptBody, parsedParams, baseImage) @@ -476,3 +484,26 @@ func getPodCommand(language backplaneApi.ScriptMetadataLanguage, scriptBody stri func ptrBool(b bool) *bool { return &b } + +func fetchManagedScriptsHeadSHA() (string, error) { + resp, err := http.Get(managedScriptsCommitAPI) //nolint:gosec + 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 index 7a1bfea6..d6b3e43e 100644 --- a/cmd/ocm-backplane/testJob/renderTestJob_test.go +++ b/cmd/ocm-backplane/testJob/renderTestJob_test.go @@ -9,6 +9,8 @@ import ( "github.com/spf13/cobra" ) +const testImage = "quay.io/test/managed-scripts:abc1234" + var _ = Describe("testJob render command", func() { var ( @@ -31,7 +33,7 @@ var _ = Describe("testJob render command", func() { _ = 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}) + sut.SetArgs([]string{"render", "--source-dir", tempDir, "--output", outputFile, "-i", testImage}) err := sut.Execute() Expect(err).To(BeNil()) @@ -50,6 +52,7 @@ var _ = Describe("testJob render command", func() { 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() { @@ -73,7 +76,7 @@ language: bash _ = 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}) + sut.SetArgs([]string{"render", "--source-dir", tempDir, "--output", outputFile, "-i", testImage}) err := sut.Execute() Expect(err).To(BeNil()) @@ -110,7 +113,7 @@ language: bash _ = 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"}) + sut.SetArgs([]string{"render", "--source-dir", tempDir, "--output", outputFile, "-p", "MY_VAR=hello", "-i", testImage}) err := sut.Execute() Expect(err).To(BeNil()) @@ -123,6 +126,32 @@ language: bash Expect(yamlStr).To(ContainSubstring("value: hello")) }) + It("should auto-resolve image from GitHub when --base-image-override is not provided", func() { + 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 + ":")) + }) + It("should fail when a required parameter is missing", func() { metadata := ` file: script.sh @@ -140,7 +169,7 @@ 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}) + sut.SetArgs([]string{"render", "--source-dir", tempDir, "-i", testImage}) err := sut.Execute() Expect(err).ToNot(BeNil()) @@ -164,7 +193,7 @@ 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"}) + sut.SetArgs([]string{"render", "--source-dir", tempDir, "-p", "INVALID_KEY=abc", "-i", testImage}) err := sut.Execute() Expect(err).ToNot(BeNil()) @@ -172,7 +201,7 @@ language: bash }) It("should fail when metadata.yaml is missing", func() { - sut.SetArgs([]string{"render", "--source-dir", tempDir}) + sut.SetArgs([]string{"render", "--source-dir", tempDir, "-i", testImage}) err := sut.Execute() Expect(err).ToNot(BeNil()) @@ -191,14 +220,14 @@ language: bash ` _ = os.WriteFile(path.Join(tempDir, "metadata.yaml"), []byte(metadata), 0600) - sut.SetArgs([]string{"render", "--source-dir", tempDir}) + 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 base-image-override when specified", func() { + It("should use the provided base image in the pod spec", func() { metadata := ` file: script.sh name: custom-img @@ -212,7 +241,7 @@ language: bash _ = 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, "--base-image-override", "quay.io/custom/image:v1"}) + sut.SetArgs([]string{"render", "--source-dir", tempDir, "--output", outputFile, "-i", "quay.io/custom/image:v1"}) err := sut.Execute() Expect(err).To(BeNil()) @@ -222,7 +251,6 @@ language: bash yamlStr := string(content) Expect(yamlStr).To(ContainSubstring("image: quay.io/custom/image:v1")) - Expect(yamlStr).NotTo(ContainSubstring(defaultBaseImage)) }) It("should render python command for python scripts", func() { @@ -239,7 +267,7 @@ language: python _ = 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}) + sut.SetArgs([]string{"render", "--source-dir", tempDir, "--output", outputFile, "-i", testImage}) err := sut.Execute() Expect(err).To(BeNil()) From 1ff1371b27165a8e7e821bb915052066c1b0b4f1 Mon Sep 17 00:00:00 2001 From: Siu Wa Wu Date: Fri, 14 Aug 2026 12:26:28 +1000 Subject: [PATCH 3/4] fix lint --- cmd/ocm-backplane/testJob/renderTestJob.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/ocm-backplane/testJob/renderTestJob.go b/cmd/ocm-backplane/testJob/renderTestJob.go index cf380763..fd8524d3 100644 --- a/cmd/ocm-backplane/testJob/renderTestJob.go +++ b/cmd/ocm-backplane/testJob/renderTestJob.go @@ -128,7 +128,8 @@ func runRenderTestJob(cmd *cobra.Command, args []string) error { if baseImage == "" { sha, err := fetchManagedScriptsHeadSHA() if err != nil { - return fmt.Errorf("failed to resolve managed-scripts image tag: %v\n\nYou 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: ...", err, baseImageRegistry) + 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) From 431f19b7b797df0b1ede43fb21f5ab75afca44cb Mon Sep 17 00:00:00 2001 From: Siu Wa Wu Date: Fri, 14 Aug 2026 12:52:00 +1000 Subject: [PATCH 4/4] address feedback --- cmd/ocm-backplane/testJob/renderTestJob.go | 42 ++++++++-- .../testJob/renderTestJob_test.go | 84 ++++++++++++++++++- 2 files changed, 116 insertions(+), 10 deletions(-) diff --git a/cmd/ocm-backplane/testJob/renderTestJob.go b/cmd/ocm-backplane/testJob/renderTestJob.go index fd8524d3..d3402792 100644 --- a/cmd/ocm-backplane/testJob/renderTestJob.go +++ b/cmd/ocm-backplane/testJob/renderTestJob.go @@ -1,6 +1,7 @@ package testjob import ( + "context" "encoding/base64" "encoding/json" "fmt" @@ -71,7 +72,7 @@ To clean up after testing: "base-image-override", "i", "", - "Container image to run the script (required). Use 'git ls-remote https://github.com/openshift/managed-scripts HEAD | cut -f1' to get the latest tag.", + "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( @@ -126,7 +127,7 @@ func runRenderTestJob(cmd *cobra.Command, args []string) error { baseImage := baseImageOverride if baseImage == "" { - sha, err := fetchManagedScriptsHeadSHA() + 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) @@ -241,13 +242,26 @@ func renderKubeObjects(metadata backplaneApi.ScriptMetadata, scriptBody string, } objects = append(objects, string(saYAML)) - // Namespaced Roles and RoleBindings + // 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 } - rules := convertPolicyRules(*roleDecl.Rules) + 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", @@ -255,10 +269,10 @@ func renderKubeObjects(metadata backplaneApi.ScriptMetadata, scriptBody string, }, ObjectMeta: metav1.ObjectMeta{ Name: name, - Namespace: *roleDecl.Namespace, + Namespace: ns, Labels: labels, }, - Rules: rules, + Rules: rulesByNamespace[ns], } roleYAML, err := yaml.Marshal(role) if err != nil { @@ -273,7 +287,7 @@ func renderKubeObjects(metadata backplaneApi.ScriptMetadata, scriptBody string, }, ObjectMeta: metav1.ObjectMeta{ Name: name, - Namespace: *roleDecl.Namespace, + Namespace: ns, Labels: labels, }, Subjects: []rbacv1.Subject{ @@ -486,8 +500,20 @@ 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) { - resp, err := http.Get(managedScriptsCommitAPI) //nolint:gosec + 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) } diff --git a/cmd/ocm-backplane/testJob/renderTestJob_test.go b/cmd/ocm-backplane/testJob/renderTestJob_test.go index d6b3e43e..e21535d2 100644 --- a/cmd/ocm-backplane/testJob/renderTestJob_test.go +++ b/cmd/ocm-backplane/testJob/renderTestJob_test.go @@ -1,8 +1,10 @@ package testjob import ( + "fmt" "os" "path" + "strings" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -126,7 +128,11 @@ language: bash Expect(yamlStr).To(ContainSubstring("value: hello")) }) - It("should auto-resolve image from GitHub when --base-image-override is not provided", func() { + 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 @@ -149,7 +155,81 @@ language: bash Expect(err).To(BeNil()) yamlStr := string(content) - Expect(yamlStr).To(ContainSubstring("image: " + baseImageRegistry + ":")) + 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() {