diff --git a/api/v1/configurationpolicy_types.go b/api/v1/configurationpolicy_types.go index 095b557b..606d58ea 100644 --- a/api/v1/configurationpolicy_types.go +++ b/api/v1/configurationpolicy_types.go @@ -39,6 +39,23 @@ func (ra RemediationAction) IsEnforce() bool { return strings.EqualFold(string(ra), string(Enforce)) } +// CustomMessage configures the compliance messages emitted by the configuration policy, to use one +// of the specified Go templates based on the current compliance. The data passed to the templates +// include a `.DefaultMessage` string variable which matches the message that would be emitted if no +// custom template was defined, and a `.Policy` object variable which contains the full current +// state of the policy. If the policy is using Kubernetes API watches (default but can be configured +// with EvaluationInterval), and the object exists, then the full state of each related object will +// be available at `.Policy.status.relatedObjects[*].object`. Otherwise, only the identifier +// information will be available there. +type CustomMessage struct { + // Compliant is the template used for the compliance message when the policy is compliant. + Compliant string `json:"compliant,omitempty"` + + // NonCompliant is the template used for the compliance message when the policy is not compliant, + // including when the status is unknown. + NonCompliant string `json:"noncompliant,omitempty"` +} + // Severity is a user-defined severity for when an object is noncompliant with this configuration // policy. The supported options are `low`, `medium`, `high`, and `critical`. // @@ -264,6 +281,7 @@ func (o *ObjectTemplate) RecordDiffWithDefault() RecordDiff { // ConfigurationPolicySpec defines the desired configuration of objects on the cluster, along with // how the controller should handle when the cluster doesn't match the configuration policy. type ConfigurationPolicySpec struct { + CustomMessage CustomMessage `json:"customMessage,omitempty"` Severity Severity `json:"severity,omitempty"` RemediationAction RemediationAction `json:"remediationAction"` EvaluationInterval EvaluationInterval `json:"evaluationInterval,omitempty"` diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index f3e686cc..af345a2c 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -94,6 +94,7 @@ func (in *ConfigurationPolicyList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ConfigurationPolicySpec) DeepCopyInto(out *ConfigurationPolicySpec) { *out = *in + out.CustomMessage = in.CustomMessage out.EvaluationInterval = in.EvaluationInterval in.NamespaceSelector.DeepCopyInto(&out.NamespaceSelector) if in.ObjectTemplates != nil { @@ -148,6 +149,21 @@ func (in *ConfigurationPolicyStatus) DeepCopy() *ConfigurationPolicyStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CustomMessage) DeepCopyInto(out *CustomMessage) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomMessage. +func (in *CustomMessage) DeepCopy() *CustomMessage { + if in == nil { + return nil + } + out := new(CustomMessage) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EvaluationInterval) DeepCopyInto(out *EvaluationInterval) { *out = *in diff --git a/controllers/configurationpolicy_controller.go b/controllers/configurationpolicy_controller.go index a6ecc512..13ec29ec 100644 --- a/controllers/configurationpolicy_controller.go +++ b/controllers/configurationpolicy_controller.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" "sync" + "text/template" "time" "github.com/go-logr/logr" @@ -706,6 +707,18 @@ func (r *ConfigurationPolicyReconciler) definitionIsDeleting() (bool, error) { return false, fmt.Errorf("v1: %v, v1beta1: %v", v1err, v1beta1err) //nolint:errorlint } +// currentlyUsingWatch determines if the dynamic watcher should be used based on +// the current compliance and the evaluation interval settings. +func currentlyUsingWatch(plc *policyv1.ConfigurationPolicy) bool { + if plc.Status.ComplianceState == policyv1.Compliant { + return plc.Spec.EvaluationInterval.IsWatchForCompliant() + } + + // If the policy is not compliant (i.e. noncompliant or unknown), fall back to the noncompliant + // evaluation interval. This is a court of guilty until proven innocent. + return plc.Spec.EvaluationInterval.IsWatchForNonCompliant() +} + // handleObjectTemplates iterates through all policy templates in a given policy and processes them. If fields are // missing on the policy (excluding objectDefinition), an error of type ErrPolicyInvalid is returned. func (r *ConfigurationPolicyReconciler) handleObjectTemplates(plc *policyv1.ConfigurationPolicy) error { @@ -720,14 +733,7 @@ func (r *ConfigurationPolicyReconciler) handleObjectTemplates(plc *policyv1.Conf return err } - // Determine if the watch library should be used based on the evaluation interval. - usingWatch := plc.Spec.EvaluationInterval.IsWatchForCompliant() - - if plc.Status.ComplianceState != policyv1.Compliant { - // If the policy is not compliant (i.e. noncompliant or unknown), fall back to the noncompliant evaluation - // interval. This is a court of guilty until proven innocent. - usingWatch = plc.Spec.EvaluationInterval.IsWatchForNonCompliant() - } + usingWatch := currentlyUsingWatch(plc) if usingWatch && r.DynamicWatcher != nil { watcherObj := plc.ObjectIdentifier() @@ -3210,7 +3216,8 @@ func (r *ConfigurationPolicyReconciler) recordInfoEvent(plc *policyv1.Configurat plc, eventType, "policy: "+plc.GetName(), - convertPolicyStatusToString(plc), + // Always use the default message for info events + defaultComplianceMessage(plc), ) } @@ -3236,7 +3243,7 @@ func (r *ConfigurationPolicyReconciler) sendComplianceEvent(instance *policyv1.C APIVersion: ownerRef.APIVersion, }, Reason: fmt.Sprintf(eventFmtStr, instance.Namespace, instance.Name), - Message: convertPolicyStatusToString(instance), + Message: r.customComplianceMessage(instance), Source: corev1.EventSource{ Component: ControllerName, Host: r.InstanceName, @@ -3279,29 +3286,130 @@ func (r *ConfigurationPolicyReconciler) sendComplianceEvent(instance *policyv1.C return r.Create(context.TODO(), event) } -// convertPolicyStatusToString to be able to pass the status as event -func convertPolicyStatusToString(plc *policyv1.ConfigurationPolicy) string { +// defaultComplianceMessage looks through the policy's Compliance and CompliancyDetails and formats +// a message that can be used for compliance events recognized by the framework. +func defaultComplianceMessage(plc *policyv1.ConfigurationPolicy) string { if plc.Status.ComplianceState == "" || plc.Status.ComplianceState == policyv1.UnknownCompliancy { return "ComplianceState is still unknown" } - result := string(plc.Status.ComplianceState) + defaultTemplate := ` + {{- range .Status.CompliancyDetails -}} + ; {{ if (index .Conditions 0) -}} + {{- (index .Conditions 0).Type }} - {{ (index .Conditions 0).Message -}} + {{- end -}} + {{- end }}` + + // `Must` is ok here because an invalid template would be caught by tests + t := template.Must(template.New("default-msg").Parse(defaultTemplate)) + + var result strings.Builder + + result.WriteString(string(plc.Status.ComplianceState)) + + if err := t.Execute(&result, plc); err != nil { + log.Error(err, "failed to execute default template", "PolicyName", plc.Name) + + // Fallback to just returning the compliance state - this will be recognized by the framework, + // but will be missing any details. + return string(plc.Status.ComplianceState) + } + + return result.String() +} + +// customComplianceMessage uses the custom template in the policy (if provided by the user) to +// format a compliance message that can be used by the framework. If an error occurs with the +// template, the default message will be used, appended with details for the error. If no custom +// template was specified for the current compliance, then the default message is used. +func (r *ConfigurationPolicyReconciler) customComplianceMessage(plc *policyv1.ConfigurationPolicy) string { + customTemplate := plc.Spec.CustomMessage.Compliant + + if plc.Status.ComplianceState != policyv1.Compliant { + customTemplate = plc.Spec.CustomMessage.NonCompliant + } + + defaultMessage := defaultComplianceMessage(plc) + + // No custom template was provided for the current situation + if customTemplate == "" { + return defaultMessage + } + + customMessage, err := r.doCustomMessage(plc, customTemplate, defaultMessage) + if err != nil { + return fmt.Sprintf("%v (failure processing the custom message: %v)", defaultMessage, err.Error()) + } + + // Add the compliance prefix if not present (it is required by the framework) + if !strings.HasPrefix(customMessage, string(plc.Status.ComplianceState)+"; ") { + customMessage = string(plc.Status.ComplianceState) + "; " + customMessage + } + + return customMessage +} + +// doCustomMessage parses and executes the custom template, returning an error if something goes +// wrong. The data that the template receives includes the '.DefaultMessage' string and a '.Policy' +// object, which has the full current state of the configuration policy, including status fields +// like relatedObjects. If the policy is using the dynamic watcher, then the '.object' field on each +// related object will have the *full* current state of that object, otherwise only some identifying +// information is available there. +func (r *ConfigurationPolicyReconciler) doCustomMessage( + plc *policyv1.ConfigurationPolicy, customTemplate string, defaultMessage string, +) (string, error) { + tmpl, err := template.New("custom-msg").Parse(customTemplate) + if err != nil { + return "", fmt.Errorf("failed to parse custom template: %w", err) + } - if plc.Status.CompliancyDetails == nil || len(plc.Status.CompliancyDetails) == 0 { - return result + // Converting the policy to a map allows users to access fields via the yaml/json field names + // (ie the lowercase versions), which they are likely more familiar with. + plcMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(plc) + if err != nil { + return "", fmt.Errorf("failed to convert policy to unstructured: %w)", err) } - for _, v := range plc.Status.CompliancyDetails { - result += "; " - for idx, cond := range v.Conditions { - result += cond.Type + " - " + cond.Message - if idx != len(v.Conditions)-1 { - result += ", " + // Only add the full related object information when it can be pulled from the cache + if currentlyUsingWatch(plc) { + // Paranoid checks to ensure that the policy has a status of the right format + plcStatus, ok := plcMap["status"].(map[string]any) + if !ok { + goto messageTemplating + } + + relObjs, ok := plcStatus["relatedObjects"].([]any) + if !ok { + goto messageTemplating + } + + for i, relObj := range plc.Status.RelatedObjects { + objNS := relObj.Object.Metadata.Namespace + objName := relObj.Object.Metadata.Name + objGVK := schema.FromAPIVersionAndKind(relObj.Object.APIVersion, relObj.Object.Kind) + + fullObj, err := r.getObjectFromCache(plc, objNS, objName, objGVK) + if err == nil && fullObj != nil { + if _, ok := relObjs[i].(map[string]any); ok { + relObjs[i].(map[string]any)["object"] = fullObj.Object + } } } } - return result +messageTemplating: + templateData := map[string]any{ + "DefaultMessage": defaultMessage, + "Policy": plcMap, + } + + var customMsg strings.Builder + + if err := tmpl.Execute(&customMsg, templateData); err != nil { + return "", fmt.Errorf("failed to execute: %w", err) + } + + return customMsg.String(), nil } // getDeployment gets the Deployment object associated with this controller. If the controller is running outside of diff --git a/controllers/configurationpolicy_controller_test.go b/controllers/configurationpolicy_controller_test.go index ebd25b6d..d4b9bdf7 100644 --- a/controllers/configurationpolicy_controller_test.go +++ b/controllers/configurationpolicy_controller_test.go @@ -166,7 +166,7 @@ func TestConvertPolicyStatusToString(t *testing.T) { CompliancyDetails: compliantDetails, } samplePolicy.Status = samplePolicyStatus - policyInString := convertPolicyStatusToString(&samplePolicy) + policyInString := defaultComplianceMessage(&samplePolicy) assert.NotNil(t, policyInString) } @@ -188,7 +188,7 @@ func TestConvertPolicyStatusToStringLongMsg(t *testing.T) { }, }, } - statusMsg := convertPolicyStatusToString(&samplePolicy) + statusMsg := defaultComplianceMessage(&samplePolicy) assert.Greater(t, len(statusMsg), 1024) } diff --git a/deploy/crds/kustomize_configurationpolicy/policy.open-cluster-management.io_configurationpolicies.yaml b/deploy/crds/kustomize_configurationpolicy/policy.open-cluster-management.io_configurationpolicies.yaml index 3df57d06..ccbeb01e 100644 --- a/deploy/crds/kustomize_configurationpolicy/policy.open-cluster-management.io_configurationpolicies.yaml +++ b/deploy/crds/kustomize_configurationpolicy/policy.open-cluster-management.io_configurationpolicies.yaml @@ -51,6 +51,27 @@ spec: ConfigurationPolicySpec defines the desired configuration of objects on the cluster, along with how the controller should handle when the cluster doesn't match the configuration policy. properties: + customMessage: + description: |- + CustomMessage configures the compliance messages emitted by the configuration policy, to use one + of the specified Go templates based on the current compliance. The data passed to the templates + include a `.DefaultMessage` string variable which matches the message that would be emitted if no + custom template was defined, and a `.Policy` object variable which contains the full current + state of the policy. If the policy is using Kubernetes API watches (default but can be configured + with EvaluationInterval), and the object exists, then the full state of each related object will + be available at `.Policy.status.relatedObjects[*].object`. Otherwise, only the identifier + information will be available there. + properties: + compliant: + description: Compliant is the template used for the compliance + message when the policy is compliant. + type: string + noncompliant: + description: |- + NonCompliant is the template used for the compliance message when the policy is not compliant, + including when the status is unknown. + type: string + type: object evaluationInterval: description: |- EvaluationInterval configures the minimum elapsed time before a configuration policy is diff --git a/deploy/crds/policy.open-cluster-management.io_configurationpolicies.yaml b/deploy/crds/policy.open-cluster-management.io_configurationpolicies.yaml index 60d44aa1..61f47467 100644 --- a/deploy/crds/policy.open-cluster-management.io_configurationpolicies.yaml +++ b/deploy/crds/policy.open-cluster-management.io_configurationpolicies.yaml @@ -58,6 +58,27 @@ spec: - required: - object-templates-raw properties: + customMessage: + description: |- + CustomMessage configures the compliance messages emitted by the configuration policy, to use one + of the specified Go templates based on the current compliance. The data passed to the templates + include a `.DefaultMessage` string variable which matches the message that would be emitted if no + custom template was defined, and a `.Policy` object variable which contains the full current + state of the policy. If the policy is using Kubernetes API watches (default but can be configured + with EvaluationInterval), and the object exists, then the full state of each related object will + be available at `.Policy.status.relatedObjects[*].object`. Otherwise, only the identifier + information will be available there. + properties: + compliant: + description: Compliant is the template used for the compliance + message when the policy is compliant. + type: string + noncompliant: + description: |- + NonCompliant is the template used for the compliance message when the policy is not compliant, + including when the status is unknown. + type: string + type: object evaluationInterval: description: |- EvaluationInterval configures the minimum elapsed time before a configuration policy is diff --git a/test/e2e/case41_custom_message_test.go b/test/e2e/case41_custom_message_test.go new file mode 100644 index 00000000..935b9352 --- /dev/null +++ b/test/e2e/case41_custom_message_test.go @@ -0,0 +1,171 @@ +// Copyright Contributors to the Open Cluster Management project + +package e2e + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "open-cluster-management.io/config-policy-controller/test/utils" +) + +var _ = Describe("Custom compliance messages", Ordered, func() { + const ( + parentYAML = "../resources/case41_custom_message/case41-parent.yaml" + parentName = "case41-parent" + cfgPolicyYAML = "../resources/case41_custom_message/case41.yaml" + policyName = "case41" + ) + + AfterAll(func(ctx SpecContext) { + By("Deleting case 41's resources") + deleteConfigPolicies([]string{"case41"}) + utils.Kubectl("-n", "default", "delete", "pod", "nginx-pod-e2e-41", "--ignore-not-found") + utils.Kubectl("delete", "namespace", "test-case-41", "--ignore-not-found") + utils.Kubectl("-n", testNamespace, "delete", "policy", parentName, "--ignore-not-found") + }) + + It("Should have the right initial event for an inform policy with an invalid message template", func() { + By("Creating the case41 ConfigurationPolicy") + createObjWithParent(parentYAML, parentName, cfgPolicyYAML, testNamespace, gvrPolicy, gvrConfigPolicy) + + By("Verifying the ConfigurationPolicy starts NonCompliant") + Eventually(func() interface{} { + managedPlc := utils.GetWithTimeout(clientManagedDynamic, gvrConfigPolicy, "case41", testNamespace, true, 5) + + return utils.GetComplianceState(managedPlc) + }, 10, 1).Should(Equal("NonCompliant")) + + By("Verifying the event has the default message, and mentions the error") + Eventually(func(g Gomega) string { + events := utils.GetMatchingEvents(clientManaged, testNamespace, parentName, "policy:", "NonCompliant;", 5) + g.Expect(events).ToNot(BeEmpty()) + + return events[len(events)-1].Message + }, 10, 1).Should(And( + ContainSubstring("NonCompliant; violation - namespaces [test-case-41] not found; "), + MatchRegexp("(failure processing the custom message: failed to parse custom template: .* unexpected EOF)"), + )) + }) + + It("Should still become compliant when enforced despite the invalid message template", func() { + By("Patching the remediationAction") + utils.Kubectl("patch", "configurationpolicy", policyName, "-n", testNamespace, "--type=json", "-p", + `[{"op": "replace", "path": "/spec/remediationAction", "value": "enforce"}]`) + + By("Verifying the ConfigurationPolicy becomes Compliant") + Eventually(func() interface{} { + managedPlc := utils.GetWithTimeout(clientManagedDynamic, gvrConfigPolicy, "case41", testNamespace, true, 5) + + return utils.GetComplianceState(managedPlc) + }, 10, 1).Should(Equal("Compliant")) + + By("Verifying the event has the default message, and mentions the error") + Eventually(func(g Gomega) string { + events := utils.GetMatchingEvents(clientManaged, testNamespace, parentName, "policy:", "^Compliant;", 5) + g.Expect(events).ToNot(BeEmpty()) + + return events[len(events)-1].Message + }, 10, 1).Should(And( + ContainSubstring("Compliant; notification - namespaces [test-case-41] found as specified; "), + MatchRegexp("(failure processing the custom message: failed to parse custom template: .* unexpected EOF)"), + )) + }) + + It("Should work with a range over the related objects", func() { + By("Patching the custom compliance message") + template := `{{ range .Policy.status.relatedObjects -}} the {{.object.kind}} ` + + `{{.object.metadata.name}} is {{.compliant}} in namespace {{.object.metadata.namespace}} ` + + `because '{{.reason}}'; {{ end }}` + utils.Kubectl("patch", "configurationpolicy", policyName, "-n", testNamespace, "--type=json", "-p", + `[{"op": "replace", "path": "/spec/customMessage/compliant", "value": "`+template+`"}]`) + + By("Verifying the event has the customized message") + Eventually(func(g Gomega) string { + events := utils.GetMatchingEvents(clientManaged, testNamespace, parentName, "policy:", "^Compliant;", 5) + g.Expect(events).ToNot(BeEmpty()) + + return events[len(events)-1].Message + }, 10, 1).Should(And( + ContainSubstring("the Namespace test-case-41 is Compliant in namespace "), + ContainSubstring(`Compliant in namespace test-case-41 because 'Resource found as expected'`), + )) + }) + + It("Should be able to access specific fields inside the related objects in event-driven mode", func() { + By("Patching the custom compliance message") + template := ` {{ range .Policy.status.relatedObjects }}{{ if eq .object.kind \"Pod\" -}}` + + `Pod {{.object.metadata.name}} is in phase '{{.object.status.phase}}'; {{ end }}{{ end }}` + utils.Kubectl("patch", "configurationpolicy", policyName, "-n", testNamespace, "--type=json", "-p", + `[{"op": "replace", "path": "/spec/customMessage/compliant", "value": "`+template+`"}]`) + + By("Verifying the event has the customized message") + Eventually(func(g Gomega) string { + events := utils.GetMatchingEvents(clientManaged, testNamespace, parentName, "policy:", "^Compliant;", 5) + g.Expect(events).ToNot(BeEmpty()) + + return events[len(events)-1].Message + }, 10, 1).Should(And( + ContainSubstring("Pod nginx-pod-e2e-41 is in phase 'Pending'"), + )) + }) + + It("Should not access extra fields inside the related objects in interval-based mode", func() { + By("Patching the evaluationInterval") + utils.Kubectl("patch", "configurationpolicy", policyName, "-n", testNamespace, "--type=json", "-p", + `[{"op": "replace", "path": "/spec/evaluationInterval", "value": {"compliant": "2s"}}]`) + + By("Verifying the event has the customized message") + Eventually(func(g Gomega) string { + events := utils.GetMatchingEvents(clientManaged, testNamespace, parentName, "policy:", "^Compliant;", 5) + g.Expect(events).ToNot(BeEmpty()) + + return events[len(events)-1].Message + }, 10, 1).Should(And( + ContainSubstring("Pod nginx-pod-e2e-41 is in phase ''"), + )) + }) + + It("Should be able to access a diff when one is available", func() { + By("Patching the policy") + template := ` {{ range .Policy.status.relatedObjects }}{{ if eq .compliant \"NonCompliant\" -}}` + + `{{.object.kind}} {{.object.metadata.name}} is NonCompliant, ` + + `with diff '{{.properties.diff}}'; {{ end }}{{ end }}` + utils.Kubectl("patch", "configurationpolicy", policyName, "-n", testNamespace, "--type=json", "-p", + `[{"op": "replace", "path": "/spec/remediationAction", "value": "inform"}, + {"op": "add", "path": "/spec/object-templates/1/objectDefinition/status", "value": {"phase": "Blue"}}, + {"op": "replace", "path": "/spec/customMessage/noncompliant", "value": "`+template+`"}]`) + + By("Verifying the event has the customized message") + Eventually(func(g Gomega) string { + events := utils.GetMatchingEvents(clientManaged, testNamespace, parentName, "policy:", "NonCompliant;", 5) + g.Expect(events).ToNot(BeEmpty()) + + return events[len(events)-1].Message + }, 10, 1).Should(And( + ContainSubstring("nginx-pod-e2e-41 is NonCompliant, with diff '--- default/nginx-pod-e2e-41 : existing"), + ContainSubstring("- phase: Pending"), + ContainSubstring("+ phase: Blue"), + )) + }) + + It("Should be able to access the default message", func() { + By("Patching the policy") + template := `Customized! But the default is good too: {{.DefaultMessage}}` + utils.Kubectl("patch", "configurationpolicy", policyName, "-n", testNamespace, "--type=json", "-p", + `[{"op": "replace", "path": "/spec/remediationAction", "value": "enforce"}, + {"op": "remove", "path": "/spec/object-templates/1/objectDefinition/status"}, + {"op": "replace", "path": "/spec/customMessage/compliant", "value": "`+template+`"}]`) + + By("Verifying the event has the customized message") + Eventually(func(g Gomega) string { + events := utils.GetMatchingEvents(clientManaged, testNamespace, parentName, "policy:", "^Compliant;", 5) + g.Expect(events).ToNot(BeEmpty()) + + return events[len(events)-1].Message + }, 10, 1).Should(And( + ContainSubstring("Customized!"), + ContainSubstring("Compliant; notification - namespaces [test-case-41] found as specified; "), + )) + }) +}) diff --git a/test/resources/case41_custom_message/case41-parent.yaml b/test/resources/case41_custom_message/case41-parent.yaml new file mode 100644 index 00000000..749b339a --- /dev/null +++ b/test/resources/case41_custom_message/case41-parent.yaml @@ -0,0 +1,10 @@ +apiVersion: policy.open-cluster-management.io/v1 +kind: Policy +metadata: + name: case41-parent + annotations: + policy.open-cluster-management.io/parent-policy-compliance-db-id: "23" +spec: + remediationAction: inform + disabled: false + policy-templates: [] diff --git a/test/resources/case41_custom_message/case41.yaml b/test/resources/case41_custom_message/case41.yaml new file mode 100644 index 00000000..8371b8cc --- /dev/null +++ b/test/resources/case41_custom_message/case41.yaml @@ -0,0 +1,36 @@ +apiVersion: policy.open-cluster-management.io/v1 +kind: ConfigurationPolicy +metadata: + name: case41 + ownerReferences: + - apiVersion: policy.open-cluster-management.io/v1 + kind: Policy + name: case41-parent + uid: 12345678-90ab-cdef-1234-567890abcdef # must be replaced before creation +spec: + customMessage: + compliant: '{{ range .nonterminated }}' + noncompliant: '{{ range .nonterminated }}' + remediationAction: inform + namespaceSelector: + exclude: ["kube-*"] + include: ["default", "test-case-41"] + object-templates: + - complianceType: musthave + objectDefinition: + apiVersion: v1 + kind: Namespace + metadata: + name: test-case-41 + - complianceType: musthave + objectDefinition: + apiVersion: v1 + kind: Pod + metadata: + name: nginx-pod-e2e-41 + spec: + containers: + - image: nginx:nonexist-v999 + name: nginx + ports: + - containerPort: 80