1
0
Fork 0
mirror of https://github.com/kyverno/policy-reporter.git synced 2024-12-14 11:57:32 +00:00

Implement MS Teams as Target (#20)

* Implement MS Teams as Target
This commit is contained in:
Frank Jogeleit 2021-03-19 12:08:45 +01:00 committed by GitHub
parent dc88751e3e
commit 591e1fc2eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 478 additions and 10 deletions

View file

@ -1,5 +1,9 @@
# Changelog
## 0.21.0
* New Target MS Teams
## 0.20.2
* Policy Reporter UI update

View file

@ -13,6 +13,7 @@ This project is in an early stage. Please let me know if anything did not work a
* [Elasticsearch](#installation-with-elasticsearch)
* [Slack](#installation-with-slack)
* [Discord](#installation-with-discord)
* [MS Teams](#installation-with-ms-teams)
* [Customization](#customization)
* [Configure Policy Priorities](#configure-policy-priorities)
* [Configure Monitoring](#monitoring)
@ -134,6 +135,29 @@ target:
![Discord](https://github.com/fjogeleit/policy-reporter/blob/main/docs/images/discord.png?raw=true)
### Installation with MS Teams
```bash
helm install policy-reporter policy-reporter/policy-reporter --set target.teams.webhook=http://hook.teams -n policy-reporter --create-namespace
```
#### Additional configurations for MS Teams
* Configure `target.teams.minimumPriority` to send only results with the configured minimumPriority or above, empty means all results. (info < warning < error)
* Configure `target.teams.skipExistingOnStartup` to skip all results who already existed before the PolicyReporter started (default: `true`).
```yaml
target:
teams:
webhook: ""
minimumPriority: ""
skipExistingOnStartup: true
```
#### Example
![MS Teams](https://github.com/fjogeleit/policy-reporter/blob/main/docs/images/ms-teams.png?raw=true)
### Customization
You can combine multiple targets by setting the required `host` or `webhook` configuration for your targets of choice. For all possible configurations checkout the `./charts/policy-reporter/values.yaml` to change any available configuration.

View file

@ -5,8 +5,8 @@ description: |
It creates Prometheus Metrics and can send rule validation events to different targets like Loki, Elasticsearch, Slack or Discord
type: application
version: 0.20.2
appVersion: 0.12.0
version: 0.21.0
appVersion: 0.13.0
dependencies:
- name: monitoring

View file

@ -18,4 +18,9 @@ slack:
discord:
webhook: {{ .Values.target.discord.webhook | quote }}
minimumPriority: {{ .Values.target.discord.minimumPriority | quote }}
skipExistingOnStartup: {{ .Values.target.discord.skipExistingOnStartup }}
skipExistingOnStartup: {{ .Values.target.discord.skipExistingOnStartup }}
teams:
webhook: {{ .Values.target.teams.webhook | quote }}
minimumPriority: {{ .Values.target.teams.minimumPriority | quote }}
skipExistingOnStartup: {{ .Values.target.teams.skipExistingOnStartup }}

View file

@ -1,7 +1,7 @@
image:
repository: fjogeleit/policy-reporter
pullPolicy: IfNotPresent
tag: 0.12.0
tag: 0.13.0
imagePullSecrets: []
@ -139,6 +139,14 @@ target:
# Skip already existing PolicyReportResults on startup
skipExistingOnStartup: true
teams:
# teams webhook address
webhook: ""
# minimum priority "" < info < warning < error
minimumPriority: ""
# Skip already existing PolicyReportResults on startup
skipExistingOnStartup: true
# Node labels for pod assignment
# ref: https://kubernetes.io/docs/user-guide/node-selection/
nodeSelector: {}

BIN
docs/images/ms-teams.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View file

@ -30,6 +30,13 @@ type Discord struct {
MinimumPriority string `mapstructure:"minimumPriority"`
}
// Teams configuration
type Teams struct {
Webhook string `mapstructure:"webhook"`
SkipExisting bool `mapstructure:"skipExistingOnStartup"`
MinimumPriority string `mapstructure:"minimumPriority"`
}
// Server configuration
type API struct {
Enabled bool `mapstructure:"enabled"`
@ -42,6 +49,7 @@ type Config struct {
Elasticsearch Elasticsearch `mapstructure:"elasticsearch"`
Slack Slack `mapstructure:"slack"`
Discord Discord `mapstructure:"discord"`
Teams Teams `mapstructure:"teams"`
API API `mapstructure:"api"`
Kubeconfig string `mapstructure:"kubeconfig"`
Namespace string `mapstructure:"namespace"`

View file

@ -14,6 +14,7 @@ import (
"github.com/fjogeleit/policy-reporter/pkg/target/elasticsearch"
"github.com/fjogeleit/policy-reporter/pkg/target/loki"
"github.com/fjogeleit/policy-reporter/pkg/target/slack"
"github.com/fjogeleit/policy-reporter/pkg/target/teams"
"k8s.io/client-go/dynamic"
v1 "k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/client-go/rest"
@ -33,6 +34,7 @@ type Resolver struct {
elasticsearchClient target.Client
slackClient target.Client
discordClient target.Client
teamsClient target.Client
}
// APIServer resolver method
@ -258,6 +260,28 @@ func (r *Resolver) DiscordClient() target.Client {
return r.discordClient
}
// TeamsClient resolver method
func (r *Resolver) TeamsClient() target.Client {
if r.teamsClient != nil {
return r.teamsClient
}
if r.config.Teams.Webhook == "" {
return nil
}
r.teamsClient = teams.NewClient(
r.config.Teams.Webhook,
r.config.Teams.MinimumPriority,
r.config.Teams.SkipExisting,
&http.Client{},
)
log.Println("[INFO] Teams configured")
return r.teamsClient
}
func (r *Resolver) TargetClients() []target.Client {
clients := make([]target.Client, 0)
@ -277,6 +301,10 @@ func (r *Resolver) TargetClients() []target.Client {
clients = append(clients, discord)
}
if teams := r.TeamsClient(); teams != nil {
clients = append(clients, teams)
}
return clients
}

View file

@ -31,6 +31,11 @@ var testConfig = &config.Config{
SkipExisting: true,
MinimumPriority: "debug",
},
Teams: config.Teams{
Webhook: "http://hook.teams:80",
SkipExisting: true,
MinimumPriority: "debug",
},
}
func Test_ResolveTarget(t *testing.T) {
@ -80,14 +85,25 @@ func Test_ResolveTarget(t *testing.T) {
t.Error("Error: Should reuse first instance")
}
})
t.Run("Teams", func(t *testing.T) {
client := resolver.TeamsClient()
if client == nil {
t.Error("Expected Client, got nil")
}
client2 := resolver.TeamsClient()
if client != client2 {
t.Error("Error: Should reuse first instance")
}
})
}
func Test_ResolveTargets(t *testing.T) {
resolver := config.NewResolver(testConfig, nil)
clients := resolver.TargetClients()
if count := len(clients); count != 4 {
t.Errorf("Expected 4 Clients, got %d", count)
if count := len(clients); count != 5 {
t.Errorf("Expected 5 Clients, got %d", count)
}
}
@ -150,6 +166,11 @@ func Test_ResolveTargetWithoutHost(t *testing.T) {
SkipExisting: true,
MinimumPriority: "debug",
},
Teams: config.Teams{
Webhook: "",
SkipExisting: true,
MinimumPriority: "debug",
},
}
t.Run("Loki", func(t *testing.T) {
@ -180,6 +201,13 @@ func Test_ResolveTargetWithoutHost(t *testing.T) {
t.Error("Expected Client to be nil if no host is configured")
}
})
t.Run("Teams", func(t *testing.T) {
resolver := config.NewResolver(config2, nil)
if resolver.TeamsClient() != nil {
t.Error("Expected Client to be nil if no host is configured")
}
})
}
func Test_ResolveResultClient(t *testing.T) {

View file

@ -135,7 +135,7 @@ func (d *client) MinimumPriority() string {
return d.minimumPriority
}
// NewClient creates a new loki.client to send Results to Loki
// NewClient creates a new loki.client to send Results to Discord
func NewClient(webhook, minimumPriority string, skipExistingOnStartup bool, httpClient httpClient) target.Client {
return &client{
webhook,

View file

@ -84,7 +84,7 @@ func (e *client) MinimumPriority() string {
return e.minimumPriority
}
// NewClient creates a new loki.client to send Results to Loki
// NewClient creates a new loki.client to send Results to Elasticsearch
func NewClient(host, index, rotation, minimumPriority string, skipExistingOnStartup bool, httpClient httpClient) target.Client {
return &client{
host,

View file

@ -188,7 +188,7 @@ func (s *client) MinimumPriority() string {
return s.minimumPriority
}
// NewClient creates a new slack.client to send Results to Loki
// NewClient creates a new slack.client to send Results to Slack
func NewClient(host, minimumPriority string, skipExistingOnStartup bool, httpClient httpClient) target.Client {
return &client{
host,

View file

@ -49,7 +49,7 @@ func (c testClient) Do(req *http.Request) (*http.Response, error) {
}, nil
}
func Test_LokiTarget(t *testing.T) {
func Test_SlackTarget(t *testing.T) {
t.Run("Send Complete Result", func(t *testing.T) {
callback := func(req *http.Request) {
if contentType := req.Header.Get("Content-Type"); contentType != "application/json; charset=utf-8" {

155
pkg/target/teams/teams.go Normal file
View file

@ -0,0 +1,155 @@
package teams
import (
"bytes"
"encoding/json"
"log"
"net/http"
"time"
"github.com/fjogeleit/policy-reporter/pkg/report"
"github.com/fjogeleit/policy-reporter/pkg/target"
"github.com/fjogeleit/policy-reporter/pkg/target/helper"
)
type httpClient interface {
Do(req *http.Request) (*http.Response, error)
}
type fact struct {
Name string `json:"name"`
Value string `json:"value"`
}
type section struct {
Title string `json:"activityTitle"`
SubTitle string `json:"activitySubtitle"`
Text string `json:"text"`
Facts []fact `json:"facts,omitempty"`
}
type payload struct {
Type string `json:"@type"`
Context string `json:"@context"`
Summary string `json:"summary,omitempty"`
ThemeColor string `json:"themeColor,omitempty"`
Sections []section `json:"sections"`
}
func colorFromPriority(p report.Priority) string {
if p == report.ErrorPriority {
return "e20b0b"
}
if p == report.WarningPriority {
return "f2c744"
}
if p == report.InfoPriority {
return "36a64f"
}
return "68c2ff"
}
func newPayload(result report.Result) payload {
facts := make([]fact, 0)
facts = append(facts, fact{"Policy", result.Policy})
if result.Rule != "" {
facts = append(facts, fact{"Rule", result.Rule})
}
facts = append(facts, fact{"Priority", result.Priority.String()})
if result.Category != "" {
facts = append(facts, fact{"Category", result.Category})
}
if result.Severity != "" {
facts = append(facts, fact{"Severity", result.Severity})
}
res := report.Resource{}
if len(result.Resources) > 0 {
res = result.Resources[0]
}
if res.Kind != "" {
facts = append(facts, fact{"Kind", res.Kind})
facts = append(facts, fact{"Name", res.Name})
facts = append(facts, fact{"UID", res.UID})
if res.Namespace != "" {
facts = append(facts, fact{"Namespace", res.Namespace})
}
facts = append(facts, fact{"API Version", res.APIVersion})
}
sections := make([]section, 0, 1)
sections = append(sections, section{
Title: "New Policy Report Result",
SubTitle: time.Now().Format(time.RFC3339),
Text: result.Message,
Facts: facts,
})
return payload{
Type: "MessageCard",
Context: "http://schema.org/extensions",
Summary: result.Message,
ThemeColor: colorFromPriority(result.Priority),
Sections: sections,
}
}
type client struct {
webhook string
minimumPriority string
skipExistingOnStartup bool
client httpClient
}
func (s *client) Send(result report.Result) {
if result.Priority < report.NewPriority(s.minimumPriority) {
return
}
payload := newPayload(result)
body := new(bytes.Buffer)
if err := json.NewEncoder(body).Encode(payload); err != nil {
log.Printf("[ERROR] TEAMS : %v\n", err.Error())
return
}
req, err := http.NewRequest("POST", s.webhook, body)
if err != nil {
log.Printf("[ERROR] TEAMS : %v\n", err.Error())
return
}
req.Header.Add("Content-Type", "application/json; charset=utf-8")
req.Header.Add("User-Agent", "Policy-Reporter")
resp, err := s.client.Do(req)
helper.HandleHTTPResponse("TEAMS", resp, err)
}
func (s *client) SkipExistingOnStartup() bool {
return s.skipExistingOnStartup
}
func (s *client) Name() string {
return "Teams"
}
func (s *client) MinimumPriority() string {
return s.minimumPriority
}
// NewClient creates a new teams.client to send Results to MS Teams
func NewClient(host, minimumPriority string, skipExistingOnStartup bool, httpClient httpClient) target.Client {
return &client{
host,
minimumPriority,
skipExistingOnStartup,
httpClient,
}
}

View file

@ -0,0 +1,208 @@
package teams_test
import (
"encoding/json"
"net/http"
"testing"
"github.com/fjogeleit/policy-reporter/pkg/report"
"github.com/fjogeleit/policy-reporter/pkg/target/teams"
)
var completeResult = report.Result{
Message: "validation error: requests and limits required. Rule autogen-check-for-requests-and-limits failed at path /spec/template/spec/containers/0/resources/requests/",
Policy: "require-requests-and-limits-required",
Rule: "autogen-check-for-requests-and-limits",
Priority: report.WarningPriority,
Status: report.Fail,
Severity: report.Heigh,
Category: "resources",
Scored: true,
Resources: []report.Resource{
{
APIVersion: "v1",
Kind: "Deployment",
Name: "nginx",
Namespace: "default",
UID: "536ab69f-1b3c-4bd9-9ba4-274a56188409",
},
},
}
var minimalResult = report.Result{
Message: "validation error: label required. Rule app-label-required failed at path /spec/template/spec/containers/0/resources/requests/",
Policy: "app-label-requirement",
Priority: report.ErrorPriority,
Status: report.Fail,
Scored: true,
}
var minimalInfoResult = report.Result{
Message: "validation error: label required. Rule app-label-required failed at path /spec/template/spec/containers/0/resources/requests/",
Policy: "app-label-requirement",
Priority: report.InfoPriority,
Status: report.Fail,
Scored: true,
}
var minimalDebugResult = report.Result{
Message: "validation error: label required. Rule app-label-required failed at path /spec/template/spec/containers/0/resources/requests/",
Policy: "app-label-requirement",
Priority: report.DebugPriority,
Status: report.Fail,
Scored: true,
}
type testClient struct {
callback func(req *http.Request)
statusCode int
}
func (c testClient) Do(req *http.Request) (*http.Response, error) {
c.callback(req)
return &http.Response{
StatusCode: c.statusCode,
}, nil
}
func Test_TeamsTarget(t *testing.T) {
t.Run("Send Complete Result", func(t *testing.T) {
callback := func(req *http.Request) {
if contentType := req.Header.Get("Content-Type"); contentType != "application/json; charset=utf-8" {
t.Errorf("Unexpected Content-Type: %s", contentType)
}
if agend := req.Header.Get("User-Agent"); agend != "Policy-Reporter" {
t.Errorf("Unexpected Host: %s", agend)
}
if url := req.URL.String(); url != "http://hook.teams:80" {
t.Errorf("Unexpected Host: %s", url)
}
payload := make(map[string]interface{})
err := json.NewDecoder(req.Body).Decode(&payload)
if err != nil {
t.Fatal(err)
}
if payload["themeColor"] != "f2c744" {
t.Errorf("Unexpected ThemeColor %s", payload["themeColor"])
}
}
client := teams.NewClient("http://hook.teams:80", "", false, testClient{callback, 200})
client.Send(completeResult)
})
t.Run("Send Minimal Result", func(t *testing.T) {
callback := func(req *http.Request) {
if contentType := req.Header.Get("Content-Type"); contentType != "application/json; charset=utf-8" {
t.Errorf("Unexpected Content-Type: %s", contentType)
}
if agend := req.Header.Get("User-Agent"); agend != "Policy-Reporter" {
t.Errorf("Unexpected Host: %s", agend)
}
if url := req.URL.String(); url != "http://hook.teams:80" {
t.Errorf("Unexpected Host: %s", url)
}
payload := make(map[string]interface{})
err := json.NewDecoder(req.Body).Decode(&payload)
if err != nil {
t.Fatal(err)
}
if payload["themeColor"] != "e20b0b" {
t.Errorf("Unexpected ThemeColor %s", payload["themeColor"])
}
}
client := teams.NewClient("http://hook.teams:80", "", false, testClient{callback, 200})
client.Send(minimalResult)
})
t.Run("Send Minimal InfoResult", func(t *testing.T) {
callback := func(req *http.Request) {
payload := make(map[string]interface{})
err := json.NewDecoder(req.Body).Decode(&payload)
if err != nil {
t.Fatal(err)
}
if payload["themeColor"] != "36a64f" {
t.Errorf("Unexpected ThemeColor %s", payload["themeColor"])
}
}
client := teams.NewClient("http://hook.teams:80", "", false, testClient{callback, 200})
client.Send(minimalInfoResult)
})
t.Run("Send Minimal Debug Result", func(t *testing.T) {
callback := func(req *http.Request) {
if contentType := req.Header.Get("Content-Type"); contentType != "application/json; charset=utf-8" {
t.Errorf("Unexpected Content-Type: %s", contentType)
}
if agend := req.Header.Get("User-Agent"); agend != "Policy-Reporter" {
t.Errorf("Unexpected Host: %s", agend)
}
if url := req.URL.String(); url != "http://hook.teams:80" {
t.Errorf("Unexpected Host: %s", url)
}
payload := make(map[string]interface{})
err := json.NewDecoder(req.Body).Decode(&payload)
if err != nil {
t.Fatal(err)
}
if payload["themeColor"] != "68c2ff" {
t.Errorf("Unexpected ThemeColor %s", payload["themeColor"])
}
}
client := teams.NewClient("http://hook.teams:80", "", false, testClient{callback, 200})
client.Send(minimalDebugResult)
})
t.Run("Send with ingored Priority", func(t *testing.T) {
callback := func(req *http.Request) {
t.Errorf("Unexpected Call")
}
client := teams.NewClient("http://localhost:9200", "error", false, testClient{callback, 200})
client.Send(completeResult)
})
t.Run("SkipExistingOnStartup", func(t *testing.T) {
callback := func(req *http.Request) {
t.Errorf("Unexpected Call")
}
client := teams.NewClient("http://localhost:9200", "", true, testClient{callback, 200})
if !client.SkipExistingOnStartup() {
t.Error("Should return configured SkipExistingOnStartup")
}
})
t.Run("Name", func(t *testing.T) {
client := teams.NewClient("http://localhost:9200", "", true, testClient{})
if client.Name() != "Teams" {
t.Errorf("Unexpected Name %s", client.Name())
}
})
t.Run("MinimumPriority", func(t *testing.T) {
client := teams.NewClient("http://localhost:9200", "debug", true, testClient{})
if client.MinimumPriority() != "debug" {
t.Errorf("Unexpected MinimumPriority %s", client.MinimumPriority())
}
})
}