Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
JustinKuli committed May 18, 2024
1 parent 343128b commit 06687a9
Show file tree
Hide file tree
Showing 11 changed files with 611 additions and 11 deletions.
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
}
40 changes: 40 additions & 0 deletions pkg/testutils/courtesies.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,53 @@
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 agains the event's EventTime - but if the event does not

Check failure on line 24 in pkg/testutils/courtesies.go

View workflow job for this annotation

GitHub Actions / golangci-lint

`agains` is a misspelling of `against` (misspell)
// 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, err = regexp.Compile(regexp.QuoteMeta(msg))
if err != nil {
// fallback to an empty pattern
msgRegex = regexp.MustCompile("")
}
}

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
}
127 changes: 127 additions & 0 deletions pkg/testutils/courtesies_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// 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},
},
}

// TODO: a bunch more test cases

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])
}
}
}
}
44 changes: 44 additions & 0 deletions pkg/testutils/toolkit.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@ package testutils
import (
"context"
"fmt"
"regexp"
"sort"

"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
gomegaTypes "github.com/onsi/gomega/types"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
)

// TODO: doc
type Toolkit struct {
client.Client
EventuallyPoll string
Expand All @@ -22,6 +27,7 @@ type Toolkit struct {
BackgroundCtx context.Context //nolint:containedctx
}

// TODO: doc
func NewToolkit(client client.Client) Toolkit {
return Toolkit{
Client: client,
Expand Down Expand Up @@ -59,6 +65,44 @@ func (tk Toolkit) CleanlyCreate(ctx context.Context, obj client.Object) error {
return createErr
}

// This regular expression is copied from
// https://github.com/open-cluster-management-io/governance-policy-framework-addon/blob/v0.13.0/controllers/statussync/policy_status_sync.go#L220
var compEventRegex = regexp.MustCompile(`(?i)^policy:\s*(?:([a-z0-9.-]+)\s*\/)?(.+)`)

// GetComplianceEvents queries the cluster and returns a sorted list of the kubernetes compliance
// events for the given policy.
func (tk Toolkit) GetComplianceEvents(
ctx context.Context, ns string, parentUID types.UID, templateName string,
) ([]corev1.Event, error) {
list := &corev1.EventList{}

err := tk.List(ctx, list, client.InNamespace(ns))
if err != nil {
return nil, err
}

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

for _, event := range list.Items {
event := event

if event.InvolvedObject.UID != parentUID {
continue
}

submatch := compEventRegex.FindStringSubmatch(event.Reason)
if len(submatch) >= 3 && submatch[2] == templateName {
events = append(events, event)
}
}

sort.SliceStable(events, func(i, j int) bool {
return events[i].Name < events[j].Name
})

return events, nil
}

// EC runs assertions on asynchronous behavior, both *E*ventually and *C*onsistently,
// using the polling and timeout settings of the toolkit. Its usage should feel familiar
// to gomega users, simply skip the `.Should(...)` call and put your matcher as the second
Expand Down
Loading

0 comments on commit 06687a9

Please sign in to comment.