Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add user-defined compliance messages #280

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions api/v1/configurationpolicy_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
//
Expand Down Expand Up @@ -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"`
Expand Down
16 changes: 16 additions & 0 deletions api/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

152 changes: 130 additions & 22 deletions controllers/configurationpolicy_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"strconv"
"strings"
"sync"
"text/template"
"time"

"github.com/go-logr/logr"
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand Down Expand Up @@ -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),
)
}

Expand All @@ -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,
Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh goodness. What is this, BASIC programming??? 😆 I had no idea this existed in Go.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"any"?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not Go without goto 😆

I'm surprised @mprahl didn't comment on this, I think I've tried to sneak in a goto before... here it's just playing the role of a break statement for the if currentlyUsingWatch section. Maybe I set the whole thing up poorly, but when I try to re-write it without goto, it seems worse to me...

	// Only add the full related object information when it can be pulled from the cache
	if currentlyUsingWatch(plc) {
		skip := false

		// Paranoid checks to ensure that the policy has a status of the right format
		plcStatus, ok := plcMap["status"].(map[string]any)
		if !ok {
			skip = true
		}

		var relObjs []any

		if !skip {
			relObjs, ok = plcStatus["relatedObjects"].([]any)
			if !ok {
				skip = true
			}
		}

		if !skip {
			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
					}
				}
			}
		}
	}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yiraeChristineKim , any is a recent keyword added to go, which just means interface{}, the empty interface. So any is "any" type. It's just shorter to write, which makes some of these type assertions nicer on unstructured things.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These assertions really are just paranoia... since it's coming from a properly typed ConfigurationPolicy, I don't think it's possible they could ever fail. But, if they do fail unchecked, it's a panic, so that would be pretty bad.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JustinKuli I decided to let the "goto" slide even though I don't like them in general. It's prevalent in the Go standard library so I let it be an artistic decision. 😆

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like using goto. Great first step @JustinKuli

}

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
Expand Down
4 changes: 2 additions & 2 deletions controllers/configurationpolicy_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ func TestConvertPolicyStatusToString(t *testing.T) {
CompliancyDetails: compliantDetails,
}
samplePolicy.Status = samplePolicyStatus
policyInString := convertPolicyStatusToString(&samplePolicy)
policyInString := defaultComplianceMessage(&samplePolicy)

assert.NotNil(t, policyInString)
}
Expand All @@ -188,7 +188,7 @@ func TestConvertPolicyStatusToStringLongMsg(t *testing.T) {
},
},
}
statusMsg := convertPolicyStatusToString(&samplePolicy)
statusMsg := defaultComplianceMessage(&samplePolicy)

assert.Greater(t, len(statusMsg), 1024)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the templates
Maybe is only to me...When I read this, I was confused with policy-template.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not just you, policy templates, object templates, and now message templates get easily confused. I'm not sure what to do about it.

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading