Skip to content

Commit

Permalink
Add Compliance Events
Browse files Browse the repository at this point in the history
Many details about the event emitted and functions that verify the form
of the fields are taken from other policy repositories. Many of the
helpful functions added to the toolkit are similarly not original, but
are an evolution of functions used in other repositories' tests.

Signed-off-by: Justin Kulikauskas <[email protected]>
  • Loading branch information
JustinKuli committed Jun 17, 2024
1 parent 20756ed commit f05d46b
Show file tree
Hide file tree
Showing 14 changed files with 988 additions and 19 deletions.
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ test-basicsuite: manifests generate $(GINKGO) $(ENVTEST) ## Run just the basic s
KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" $(GINKGO) \
--coverpkg=./... --covermode=count --coverprofile=cover-basic.out ./test/fakepolicy/test/basic

test-compsuite: manifests generate $(GINKGO) $(ENVTEST) ## Run just the compliance event tests
KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" $(GINKGO) \
--coverpkg=./... --covermode=count --coverprofile=cover-comp.out ./test/fakepolicy/test/compliance

.PHONY: fuzz-test
fuzz-test:
go test ./api/v1beta1 -fuzz=FuzzMatchesExcludeAll -fuzztime=20s
Expand Down
24 changes: 24 additions & 0 deletions api/v1beta1/policycore_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,27 @@ type PolicyCore struct {
Spec PolicyCoreSpec `json:"spec,omitempty"`
Status PolicyCoreStatus `json:"status,omitempty"`
}

//+kubebuilder:object:generate=false

// PolicyLike is an interface that policies should implement so that they can
// benefit from some of the general tools in the nucleus.
type PolicyLike interface {
client.Object

// The ComplianceState (Compliant/NonCompliant) of the specific policy.
ComplianceState() ComplianceState

// A human-readable string describing the current state of the policy, and why it is either
// Compliant or NonCompliant.
ComplianceMessage() string

// The "parent" object on this cluster for the specific policy. Generally a Policy, in the API
// GroupVersion `policy.open-cluster-management.io/v1`. For namespaced kinds of policies, this
// will usually be the owner of the policy. For cluster-scoped policies, this must be stored
// some other way.
Parent() metav1.OwnerReference

// The namespace of the "parent" object.
ParentNamespace() string
}
132 changes: 132 additions & 0 deletions pkg/compliance/k8sEventEmitter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright Contributors to the Open Cluster Management project

package compliance

import (
"context"
"fmt"
"time"

corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"

nucleusv1beta1 "open-cluster-management.io/governance-policy-nucleus/api/v1beta1"
)

// K8sEmitter is an emitter of Kubernetes events which the policy framework
// watches for in order to aggregate and report policy status.
type K8sEmitter struct {
// Client is a Kubernetes client for the cluster where the compliance events
// will be created.
Client client.Client

// Source contains optional information for where the event comes from.
Source corev1.EventSource

// Mutators modify the Event after the fields are initially set, but before
// it is created on the cluster. They are run in the order they are defined.
Mutators []func(corev1.Event) (corev1.Event, error)
}

// Emit creates the Kubernetes Event on the cluster. It returns an error if the
// API call fails.
func (e K8sEmitter) Emit(ctx context.Context, pl nucleusv1beta1.PolicyLike) error {
_, err := e.EmitEvent(ctx, pl)

return err
}

// EmitEvent creates the Kubernetes Event on the cluster. It returns the Event
// that was (at least) attempted to be created, and an error if the API call
// fails.
func (e K8sEmitter) EmitEvent(ctx context.Context, pl nucleusv1beta1.PolicyLike) (*corev1.Event, error) {
plGVK := pl.GetObjectKind().GroupVersionKind()
time := time.Now()

// This event name matches the convention of recorders from client-go
name := fmt.Sprintf("%v.%x", pl.Parent().Name, time.UnixNano())

// The reason must match a pattern looked for by the policy framework
var reason string
if ns := pl.GetNamespace(); ns != "" {
reason = "policy: " + ns + "/" + pl.GetName()
} else {
reason = "policy: " + pl.GetName()
}

// The message must begin with the compliance, then should go into a descriptive message
message := string(pl.ComplianceState()) + "; " + pl.ComplianceMessage()

evType := "Normal"
if pl.ComplianceState() != nucleusv1beta1.Compliant {
evType = "Warning"
}

src := corev1.EventSource{
Component: e.Source.Component,
Host: e.Source.Host,
}

// These fields are required for the event to function as expected
if src.Component == "" {
src.Component = "policy-nucleus-default"
}

if src.Host == "" {
src.Host = "policy-nucleus-default"
}

event := corev1.Event{
TypeMeta: metav1.TypeMeta{
Kind: "Event",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: pl.ParentNamespace(),
Labels: pl.GetLabels(),
Annotations: pl.GetAnnotations(),
},
InvolvedObject: corev1.ObjectReference{
Kind: pl.Parent().Kind,
Namespace: pl.ParentNamespace(),
Name: pl.Parent().Name,
UID: pl.Parent().UID,
APIVersion: pl.Parent().APIVersion,
},
Reason: reason,
Message: message,
Source: src,
FirstTimestamp: metav1.NewTime(time),
LastTimestamp: metav1.NewTime(time),
Count: 1,
Type: evType,
EventTime: metav1.NewMicroTime(time),
Series: nil,
Action: "ComplianceStateUpdate",
Related: &corev1.ObjectReference{
Kind: plGVK.Kind,
Namespace: pl.GetNamespace(),
Name: pl.GetName(),
UID: pl.GetUID(),
APIVersion: plGVK.GroupVersion().String(),
ResourceVersion: pl.GetResourceVersion(),
},
ReportingController: src.Component,
ReportingInstance: src.Host,
}

for _, mutatorFunc := range e.Mutators {
var err error

event, err = mutatorFunc(event)
if err != nil {
return nil, err
}
}

err := e.Client.Create(ctx, &event)

return &event, err
}
36 changes: 36 additions & 0 deletions pkg/testutils/courtesies.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,49 @@
package testutils

import (
"regexp"
"time"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
)

// ObjNN returns a NamespacedName for the given Object.
func ObjNN(obj client.Object) types.NamespacedName {
return types.NamespacedName{
Namespace: obj.GetNamespace(),
Name: obj.GetName(),
}
}

// EventFilter filters the given events. Any of the filter parameters can be passed an empty
// value to ignore that field when filtering. The msg parameter will be compiled into a regex if
// possible. The since parameter checks against the event's EventTime - but if the event does not
// specify an EventTime, it will not be filtered out.
func EventFilter(evs []corev1.Event, evType, msg string, since time.Time) []corev1.Event {
msgRegex, err := regexp.Compile(msg)
if err != nil {
msgRegex = regexp.MustCompile(regexp.QuoteMeta(msg))
}

ans := make([]corev1.Event, 0)

for _, ev := range evs {
if evType != "" && ev.Type != evType {
continue
}

if !msgRegex.MatchString(ev.Message) {
continue
}

if !ev.EventTime.IsZero() && since.After(ev.EventTime.Time) {
continue
}

ans = append(ans, ev)
}

return ans
}
155 changes: 155 additions & 0 deletions pkg/testutils/courtesies_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Copyright Contributors to the Open Cluster Management project

package testutils

import (
"testing"
"time"

corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/controller-runtime/pkg/client"
)

func TestObjNN(t *testing.T) {
t.Parallel()

tests := map[string]struct {
inpObj client.Object
wantName string
wantNS string
}{
"namespaced unstructured": {
inpObj: &unstructured.Unstructured{Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "foo",
"namespace": "world",
},
}},
wantName: "foo",
wantNS: "world",
},
"cluster-scoped unstructured": {
inpObj: &unstructured.Unstructured{Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "bar",
},
}},
wantName: "bar",
wantNS: "",
},
"(namespaced) configmap": {
inpObj: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{
Name: "my-cm",
Namespace: "kube-one",
}},
wantName: "my-cm",
wantNS: "kube-one",
},
"(cluster-scoped) node": {
inpObj: &corev1.Node{ObjectMeta: metav1.ObjectMeta{
Name: "unit-tests-only",
}},
wantName: "unit-tests-only",
wantNS: "",
},
}

for name, tcase := range tests {
got := ObjNN(tcase.inpObj)

if got.Name != tcase.wantName {
t.Errorf("Wanted name '%v', got '%v' in test '%v'", tcase.wantName, got.Name, name)
}

if got.Namespace != tcase.wantNS {
t.Errorf("Wanted namespace '%v', got '%v' in test '%v'", tcase.wantNS, got.Namespace, name)
}
}
}

func TestEventFilter(t *testing.T) {
t.Parallel()

now := time.Now()
old := now.Add(-time.Minute)
veryOld := now.Add(-time.Hour)

sampleEvents := []corev1.Event{{
Message: "hello",
Type: "Normal",
EventTime: metav1.NewMicroTime(veryOld),
}, {
Message: "goodbye",
Type: "Warning",
EventTime: metav1.NewMicroTime(old),
}, {
Message: "carpe diem [",
Type: "Normal",
EventTime: metav1.NewMicroTime(now),
}, {
Message: "what time is it?",
Type: "Warning",
}}

tests := map[string]struct {
inpType string
inpMsg string
inpSince time.Time
wantIdxs []int
}{
"#NoFilter": {
inpType: "",
inpMsg: "",
inpSince: time.Time{},
wantIdxs: []int{0, 1, 2, 3},
},
"recent events, plus the one with no time specified": {
inpType: "",
inpMsg: "",
inpSince: now.Add(-5 * time.Minute),
wantIdxs: []int{1, 2, 3},
},
"only warnings": {
inpType: "Warning",
inpMsg: "",
inpSince: time.Time{},
wantIdxs: []int{1, 3},
},
"basic regex for a space": {
inpType: "",
inpMsg: ".* .*",
inpSince: time.Time{},
wantIdxs: []int{2, 3},
},
"just a space": {
inpType: "",
inpMsg: " ",
inpSince: time.Time{},
wantIdxs: []int{2, 3},
},
"invalid inescaped regex": {
inpType: "",
inpMsg: "[",
inpSince: time.Time{},
wantIdxs: []int{2},
},
}

for name, tcase := range tests {
got := EventFilter(sampleEvents, tcase.inpType, tcase.inpMsg, tcase.inpSince)

if len(got) != len(tcase.wantIdxs) {
t.Fatalf("Expected %v events to be returned, got %v in test %v: got events: %v",
len(tcase.wantIdxs), len(got), name, got)
}

for i, wantIdx := range tcase.wantIdxs {
if sampleEvents[wantIdx].String() != got[i].String() {
t.Errorf("Mismatch on item #%v in test %v. Expected '%v' got '%v'",
i, name, sampleEvents[wantIdx], got[i])
}
}
}
}
Loading

0 comments on commit f05d46b

Please sign in to comment.