Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
JustinKuli committed May 20, 2024
1 parent 343128b commit 9955bae
Show file tree
Hide file tree
Showing 12 changed files with 791 additions and 18 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 basic suite of 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 be worked with
// generally, without worrying about the specific kind of policy.
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
}
111 changes: 111 additions & 0 deletions pkg/compliance/k8sEventEmitter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// 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"
)

// Can't be an interface because then Emit couldn't be a method
type K8sEmitter struct {
Client client.Client // TODO: required
Source corev1.EventSource // TODO: optional?

// TODO: debatable for inclusion; allows tweaks of the event like adding/removing labels
// if not included, we could skip creating the event, just build it for the user to send
// but if it is included, we can build bigger things on top of this, and still have extensibility
Mutators []func(corev1.Event) (corev1.Event, error)
}

// TODO: maybe this would be a good interface for multiple emitters?
func (e K8sEmitter) Emit(ctx context.Context, pl nucleusv1beta1.PolicyLike) error {
_, err := e.EmitEvent(ctx, pl)

return err
}

// TODO: doc
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"
}

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: e.Source,
FirstTimestamp: metav1.NewTime(time),
LastTimestamp: metav1.NewTime(time),
Count: 1,
Type: evType,
EventTime: metav1.NewMicroTime(time), // does this break anything?
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: e.Source.Component,
ReportingInstance: e.Source.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 9955bae

Please sign in to comment.