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

Merge pull request #9 from fjogeleit/development

Development
This commit is contained in:
Frank Jogeleit 2021-02-28 13:03:36 +01:00 committed by GitHub
commit a949b409cd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 439 additions and 92 deletions

View file

@ -1,5 +1,9 @@
# Changelog
## 0.9.0
* Implement Discord as Target for PolicyReportResults
## 0.8.0
* Implement Slack as Target for PolicyReportResults

View file

@ -28,6 +28,17 @@ helm install policy-reporter policy-reporter/policy-reporter -n policy-reporter
```bash
helm install policy-reporter policy-reporter/policy-reporter --set loki.host=http://loki:3100 -n policy-reporter --create-namespace
```
#### Additional configurations for Loki
* Configure `loki.minimumPriority` to send only results with the configured minimumPriority or above, empty means all results. (info < warning < error)
* Configure `loki.skipExistingOnStartup` to skip all results who already existed before the PolicyReporter started (default: `true`).
```yaml
loki:
host: ""
minimumPriority: ""
skipExistingOnStartup: true
```
### Installation with Elasticsearch
@ -35,26 +46,7 @@ helm install policy-reporter policy-reporter/policy-reporter --set loki.host=htt
helm install policy-reporter policy-reporter/policy-reporter --set elasticsearch.host=http://elasticsearch:3100 -n policy-reporter --create-namespace
```
### Installation with Slack
```bash
helm install policy-reporter policy-reporter/policy-reporter --set slack.webhook=http://hook.slack -n policy-reporter --create-namespace
```
You can also customize the `./charts/policy-reporter/values.yaml` to change the default configurations.
### Additional configurations for Loki
* Configure `loki.minimumPriority` to send only results with the configured minimumPriority or above, empty means all results. (info < warning < error)
* Configure `loki.skipExistingOnStartup` to skip all results who already existed before the PolicyReporter started (default: `true`).
```yaml
loki:
minimumPriority: ""
skipExistingOnStartup: true
```
### Additional configurations for Elasticsearch
#### Additional configurations for Elasticsearch
* Configure `elasticsearch.index` to customize the elasticsearch index.
* Configure `elasticsearch.rotation` is added as suffix to the index. Possible values are `daily`, `monthly`, `annually` and `none`.
@ -63,12 +55,20 @@ loki:
```yaml
elasticsearch:
host: ""
index: "policy-reporter"
rotation: "daily"
minimumPriority: ""
skipExistingOnStartup: true
```
### Installation with Slack
```bash
helm install policy-reporter policy-reporter/policy-reporter --set slack.webhook=http://hook.slack -n policy-reporter --create-namespace
```
### Additional configurations for Slack
* Configure `slack.minimumPriority` to send only results with the configured minimumPriority or above, empty means all results. (info < warning < error)
@ -76,10 +76,33 @@ elasticsearch:
```yaml
slack:
webhook: ""
minimumPriority: ""
skipExistingOnStartup: true
```
### Installation with Discord
```bash
helm install policy-reporter policy-reporter/policy-reporter --set discord.webhook=http://hook.discord -n policy-reporter --create-namespace
```
#### Additional configurations for Discord
* Configure `discord.minimumPriority` to send only results with the configured minimumPriority or above, empty means all results. (info < warning < error)
* Configure `discord.skipExistingOnStartup` to skip all results who already existed before the PolicyReporter started (default: `true`).
```yaml
discord:
webhook: ""
minimumPriority: ""
skipExistingOnStartup: 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 configurations available.
### Configure Policy Priorities
By default kyverno PolicyReports has no priority or severity for policies. So every passed rule validation will be processed as notice, a failed validation is processed as error. To customize this you can configure a mapping from policies to fail priorities. So you can send them as warnings instead of errors. To configure the priorities create a ConfigMap in the `policy-reporter` namespace with the name `policy-reporter-priorities`. Configure each priority as value with the Policyname as key and the Priority as value. This Configuration is loaded and synchronized during runtime. Any change to this configmap will automaticly synchronized, no new deployment needed.

View file

@ -3,5 +3,5 @@ name: policy-reporter
description: K8s PolicyReporter watches for wgpolicyk8s.io/v1alpha1.PolicyReport resources. It creates Prometheus Metrics and can send rule validation events to Loki
type: application
version: 0.9.0
appVersion: 0.8.0
version: 0.10.0
appVersion: 0.9.0

View file

@ -20,5 +20,10 @@ data:
slack:
webhook: {{ .Values.slack.webhook | quote }}
minimumPriority: {{ .Values.loki.minimumPriority | quote }}
skipExistingOnStartup: {{ .Values.loki.skipExistingOnStartup }}
minimumPriority: {{ .Values.slack.minimumPriority | quote }}
skipExistingOnStartup: {{ .Values.slack.skipExistingOnStartup }}
discord:
webhook: {{ .Values.discord.webhook | quote }}
minimumPriority: {{ .Values.discord.minimumPriority | quote }}
skipExistingOnStartup: {{ .Values.discord.skipExistingOnStartup }}

View file

@ -27,6 +27,14 @@ slack:
# Skip already existing PolicyReportResults on startup
skipExistingOnStartup: true
discord:
# discord app webhook address
webhook: ""
# minimum priority "" < info < warning < error
minimumPriority: ""
# Skip already existing PolicyReportResults on startup
skipExistingOnStartup: true
metrics:
serviceMonitor: false
dashboard:
@ -36,7 +44,7 @@ metrics:
image:
repository: fjogeleit/policy-reporter
pullPolicy: IfNotPresent
tag: 0.8.0
tag: 0.9.0
imagePullSecrets: []

View file

@ -23,11 +23,19 @@ type Slack struct {
MinimumPriority string `mapstructure:"minimumPriority"`
}
// Discord configuration
type Discord struct {
Webhook string `mapstructure:"webhook"`
SkipExisting bool `mapstructure:"skipExistingOnStartup"`
MinimumPriority string `mapstructure:"minimumPriority"`
}
// Config of the PolicyReporter
type Config struct {
Loki Loki `mapstructure:"loki"`
Elasticsearch Elasticsearch `mapstructure:"elasticsearch"`
Slack Slack `mapstructure:"slack"`
Discord Discord `mapstructure:"discord"`
Kubeconfig string `mapstructure:"kubeconfig"`
Namespace string `mapstructure:"namespace"`
}

View file

@ -2,6 +2,7 @@ package config
import (
"context"
"log"
"net/http"
"time"
@ -9,6 +10,7 @@ import (
"github.com/fjogeleit/policy-reporter/pkg/metrics"
"github.com/fjogeleit/policy-reporter/pkg/report"
"github.com/fjogeleit/policy-reporter/pkg/target"
"github.com/fjogeleit/policy-reporter/pkg/target/discord"
"github.com/fjogeleit/policy-reporter/pkg/target/elasticsearch"
"github.com/fjogeleit/policy-reporter/pkg/target/loki"
"github.com/fjogeleit/policy-reporter/pkg/target/slack"
@ -21,6 +23,7 @@ type Resolver struct {
lokiClient target.Client
elasticsearchClient target.Client
slackClient target.Client
discordClient target.Client
policyReportMetrics metrics.Metrics
clusterPolicyReportMetrics metrics.Metrics
}
@ -60,6 +63,8 @@ func (r *Resolver) LokiClient() target.Client {
&http.Client{},
)
log.Println("[INFO] Loki configured")
return r.lokiClient
}
@ -88,10 +93,12 @@ func (r *Resolver) ElasticsearchClient() target.Client {
&http.Client{},
)
log.Println("[INFO] Elasticsearch configured")
return r.elasticsearchClient
}
// ElasticsearchClient resolver method
// SlackClient resolver method
func (r *Resolver) SlackClient() target.Client {
if r.slackClient != nil {
return r.slackClient
@ -108,9 +115,33 @@ func (r *Resolver) SlackClient() target.Client {
&http.Client{},
)
log.Println("[INFO] Slack configured")
return r.slackClient
}
// DiscordClient resolver method
func (r *Resolver) DiscordClient() target.Client {
if r.discordClient != nil {
return r.discordClient
}
if r.config.Discord.Webhook == "" {
return nil
}
r.discordClient = discord.NewClient(
r.config.Discord.Webhook,
r.config.Discord.MinimumPriority,
r.config.Discord.SkipExisting,
&http.Client{},
)
log.Println("[INFO] Discord configured")
return r.discordClient
}
// PolicyReportMetrics resolver method
func (r *Resolver) PolicyReportMetrics() (metrics.Metrics, error) {
if r.policyReportMetrics != nil {
@ -158,6 +189,10 @@ func (r *Resolver) TargetClients() []target.Client {
clients = append(clients, slack)
}
if discord := r.DiscordClient(); discord != nil {
clients = append(clients, discord)
}
return clients
}

View file

@ -20,15 +20,21 @@ var testConfig = &config.Config{
MinimumPriority: "debug",
},
Slack: config.Slack{
Webhook: "http://localhost:80",
Webhook: "http://hook.slack:80",
SkipExisting: true,
MinimumPriority: "debug",
},
Discord: config.Discord{
Webhook: "http://hook.discord:80",
SkipExisting: true,
MinimumPriority: "debug",
},
}
func Test_ResolveLokiClient(t *testing.T) {
func Test_ResolveClient(t *testing.T) {
resolver := config.NewResolver(testConfig)
t.Run("Loki", func(t *testing.T) {
client := resolver.LokiClient()
if client == nil {
t.Error("Expected Client, got nil")
@ -38,11 +44,8 @@ func Test_ResolveLokiClient(t *testing.T) {
if client != client2 {
t.Error("Error: Should reuse first instance")
}
}
func Test_ResolveElasticSearchClient(t *testing.T) {
resolver := config.NewResolver(testConfig)
})
t.Run("Elasticsearch", func(t *testing.T) {
client := resolver.ElasticsearchClient()
if client == nil {
t.Error("Expected Client, got nil")
@ -52,11 +55,8 @@ func Test_ResolveElasticSearchClient(t *testing.T) {
if client != client2 {
t.Error("Error: Should reuse first instance")
}
}
func Test_ResolveSlackClient(t *testing.T) {
resolver := config.NewResolver(testConfig)
})
t.Run("Slack", func(t *testing.T) {
client := resolver.SlackClient()
if client == nil {
t.Error("Expected Client, got nil")
@ -66,14 +66,26 @@ func Test_ResolveSlackClient(t *testing.T) {
if client != client2 {
t.Error("Error: Should reuse first instance")
}
})
t.Run("Discord", func(t *testing.T) {
client := resolver.DiscordClient()
if client == nil {
t.Error("Expected Client, got nil")
}
client2 := resolver.DiscordClient()
if client != client2 {
t.Error("Error: Should reuse first instance")
}
})
}
func Test_ResolveTargets(t *testing.T) {
resolver := config.NewResolver(testConfig)
clients := resolver.TargetClients()
if count := len(clients); count != 3 {
t.Errorf("Expected 3 Clients, got %d", count)
if count := len(clients); count != 4 {
t.Errorf("Expected 4 Clients, got %d", count)
}
}
@ -114,25 +126,13 @@ func Test_ResolveSkipExistingOnStartup(t *testing.T) {
})
}
func Test_ResolveLokiClientWithoutHost(t *testing.T) {
func Test_ResolveClientWithoutHost(t *testing.T) {
config2 := &config.Config{
Loki: config.Loki{
Host: "",
SkipExisting: true,
MinimumPriority: "debug",
},
}
resolver := config.NewResolver(config2)
resolver.Reset()
if resolver.LokiClient() != nil {
t.Error("Expected Client to be nil if no host is configured")
}
}
func Test_ResolveElasticsearchClientWithoutHost(t *testing.T) {
config2 := &config.Config{
Elasticsearch: config.Elasticsearch{
Host: "",
Index: "policy-reporter",
@ -140,27 +140,48 @@ func Test_ResolveElasticsearchClientWithoutHost(t *testing.T) {
SkipExisting: true,
MinimumPriority: "debug",
},
}
resolver := config.NewResolver(config2)
if resolver.ElasticsearchClient() != nil {
t.Error("Expected Client to be nil if no host is configured")
}
}
func Test_ResolveSlackClientWithoutHost(t *testing.T) {
config2 := &config.Config{
Slack: config.Slack{
Webhook: "",
SkipExisting: true,
MinimumPriority: "debug",
},
Discord: config.Discord{
Webhook: "",
SkipExisting: true,
MinimumPriority: "debug",
},
}
t.Run("Loki", func(t *testing.T) {
resolver := config.NewResolver(config2)
resolver.Reset()
if resolver.LokiClient() != nil {
t.Error("Expected Client to be nil if no host is configured")
}
})
t.Run("Elasticsearch", func(t *testing.T) {
resolver := config.NewResolver(config2)
resolver.Reset()
if resolver.ElasticsearchClient() != nil {
t.Error("Expected Client to be nil if no host is configured")
}
})
t.Run("Slack", func(t *testing.T) {
resolver := config.NewResolver(config2)
resolver.Reset()
if resolver.SlackClient() != nil {
t.Error("Expected Client to be nil if no host is configured")
}
})
t.Run("Discord", func(t *testing.T) {
resolver := config.NewResolver(config2)
resolver.Reset()
if resolver.DiscordClient() != nil {
t.Error("Expected Client to be nil if no host is configured")
}
})
}

View file

@ -0,0 +1,152 @@
package discord
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/fjogeleit/policy-reporter/pkg/report"
"github.com/fjogeleit/policy-reporter/pkg/target"
)
type payload struct {
Content string `json:"content"`
Embeds []embed `json:"embeds"`
}
type embed struct {
Title string `json:"title"`
Description string `json:"description"`
Color string `json:"color"`
Fields []embedField `json:"fields"`
}
type embedField struct {
Name string `json:"name"`
Value string `json:"value"`
Inline bool `json:"inline"`
}
func newPayload(result report.Result) payload {
var color string
switch result.Priority {
case report.ErrorPriority:
color = "15158332"
case report.WarningPriority:
color = "15105570"
case report.InfoPriority:
color = "3066993"
case report.DebugPriority:
color = "12370112"
}
embedFields := make([]embedField, 0)
embedFields = append(embedFields, embedField{"Policy", result.Policy, true})
if result.Rule != "" {
embedFields = append(embedFields, embedField{"Rule", result.Rule, true})
}
embedFields = append(embedFields, embedField{"Priority", result.Priority.String(), true})
if result.Category != "" {
embedFields = append(embedFields, embedField{"Category", result.Category, true})
}
if result.Severity != "" {
embedFields = append(embedFields, embedField{"Severity", result.Severity, true})
}
res := report.Resource{}
if len(result.Resources) > 0 {
res = result.Resources[0]
}
if res.Kind != "" {
embedFields = append(embedFields, embedField{"Kind", res.Kind, true})
embedFields = append(embedFields, embedField{"Name", res.Name, true})
if res.Namespace != "" {
embedFields = append(embedFields, embedField{"Namespace", res.Namespace, true})
}
embedFields = append(embedFields, embedField{"API Version", res.APIVersion, true})
}
embeds := make([]embed, 0, 1)
embeds = append(embeds, embed{
Title: "New Policy Report Result",
Description: result.Message,
Color: color,
Fields: embedFields,
})
return payload{
Content: "",
Embeds: embeds,
}
}
type httpClient interface {
Do(req *http.Request) (*http.Response, error)
}
type client struct {
webhook string
minimumPriority string
skipExistingOnStartup bool
client httpClient
}
func (d *client) Send(result report.Result) {
if result.Priority < report.NewPriority(d.minimumPriority) {
return
}
payload := newPayload(result)
body := new(bytes.Buffer)
if err := json.NewEncoder(body).Encode(payload); err != nil {
log.Printf("[ERROR] DISCORD : %v\n", err.Error())
}
req, err := http.NewRequest("POST", d.webhook, body)
if err != nil {
log.Printf("[ERROR] DISCORD : %v\n", err.Error())
}
req.Header.Add("Content-Type", "application/json; charset=utf-8")
req.Header.Add("User-Agent", "Policy-Reporter")
resp, err := d.client.Do(req)
defer func() {
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
}()
if err != nil {
log.Printf("[ERROR] DISCORD PUSH failed: %s\n", err.Error())
} else if resp.StatusCode >= 400 {
fmt.Printf("StatusCode: %d\n", resp.StatusCode)
buf := new(bytes.Buffer)
buf.ReadFrom(resp.Body)
log.Printf("[ERROR] DISCORD PUSH failed [%d]: %s\n", resp.StatusCode, buf.String())
} else {
log.Println("[INFO] DISCORD PUSH OK")
}
}
func (d *client) SkipExistingOnStartup() bool {
return d.skipExistingOnStartup
}
// NewClient creates a new loki.client to send Results to Loki
func NewClient(webhook, minimumPriority string, skipExistingOnStartup bool, httpClient httpClient) target.Client {
return &client{
webhook,
minimumPriority,
skipExistingOnStartup,
httpClient,
}
}

View file

@ -0,0 +1,90 @@
package discord_test
import (
"net/http"
"testing"
"github.com/fjogeleit/policy-reporter/pkg/report"
"github.com/fjogeleit/policy-reporter/pkg/target/discord"
)
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.ErrorPriority,
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.WarningPriority,
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_LokiTarget(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.discord:80" {
t.Errorf("Unexpected Host: %s", url)
}
}
slack := discord.NewClient("http://hook.discord:80", "", false, testClient{callback, 200})
slack.Send(minimalResult)
})
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.discord:80" {
t.Errorf("Unexpected Host: %s", url)
}
}
slack := discord.NewClient("http://hook.discord:80", "", false, testClient{callback, 200})
slack.Send(minimalResult)
})
}

View file

@ -60,6 +60,7 @@ func newLokiPayload(result report.Result) payload {
if res.Kind != "" {
labels = append(labels, "kind=\""+res.Kind+"\"")
labels = append(labels, "name=\""+res.Name+"\"")
labels = append(labels, "apiVersion=\""+res.APIVersion+"\"")
labels = append(labels, "uid=\""+res.UID+"\"")
labels = append(labels, "namespace=\""+res.Namespace+"\"")
}