diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..b50a3860 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + # Publish `master` as Docker `latest` image. + branches: + - main + + # Publish `v1.2.3` tags as releases. + tags: + - v* + + paths-ignore: + - README.md + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Set up Go 1.16 + uses: actions/setup-go@v1 + with: + go-version: 1.16 + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + - name: Get dependencies + run: go get -v -t -d ./... + - name: Test + run: make test + + coverage: + runs-on: ubuntu-latest + steps: + - name: Set up Go 1.16 + uses: actions/setup-go@v2 + with: + go-version: 1.16 + - name: Checkout code + uses: actions/checkout@v2 + - name: Get dependencies + run: go get -v -t -d ./... + - name: Calc coverage + run: make coverage + - name: Convert coverage to lcov + uses: jandelgado/gcov2lcov-action@v1.0.8 + - name: Coveralls + uses: coverallsapp/github-action@v1.1.2 + with: + github-token: ${{ secrets.github_token }} + path-to-lcov: coverage.lcov \ No newline at end of file diff --git a/Makefile b/Makefile index 2e8b460f..0393e528 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,14 @@ clean: prepare: mkdir -p $(BUILD) +.PHONY: test +test: + go test -v ./... + +.PHONY: coverage +coverage: + go test -v ./... -covermode=count -coverprofile=coverage.out + .PHONY: build build: prepare CGO_ENABLED=0 $(GO) build -v -ldflags="-s -w" $(GOFLAGS) -o $(BUILD)/policyreporter . diff --git a/README.md b/README.md index 64f81ebb..457728e3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # PolicyReporter +[![CI](https://github.com/fjogeleit/policy-reporter/actions/workflows/ci.yaml/badge.svg)](https://github.com/fjogeleit/policy-reporter/actions/workflows/ci.yaml) ## Motivation diff --git a/charts/policy-reporter/templates/deployment.yaml b/charts/policy-reporter/templates/deployment.yaml index 0cdc59c1..036c46ea 100644 --- a/charts/policy-reporter/templates/deployment.yaml +++ b/charts/policy-reporter/templates/deployment.yaml @@ -31,7 +31,7 @@ spec: - --loki-minimum-priority={{ .Values.loki.minimumPriority }} {{- end }} {{- if .Values.loki.skipExistingOnStartup }} - - --loki-skip-exising-on-startup + - --loki-skip-existing-on-startup {{- end }} ports: - name: http diff --git a/cmd/root.go b/cmd/root.go index 7a24b537..53483079 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,23 +12,14 @@ import ( "golang.org/x/sync/errgroup" ) -type PolicySeverity = string - -const ( - Fail PolicySeverity = "fail" - Warn PolicySeverity = "warn" - Error PolicySeverity = "error" - Pass PolicySeverity = "pass" - Skip PolicySeverity = "skip" -) - +// NewCLI creates a new instance of the root CLI func NewCLI() *cobra.Command { rootCmd := &cobra.Command{ Use: "run", Short: "Kyverno Policy API", Long: `Kyverno Policy API and Monitoring`, RunE: func(cmd *cobra.Command, args []string) error { - c, err := LoadConfig(cmd) + c, err := loadConfig(cmd) if err != nil { return err } @@ -80,14 +71,14 @@ func NewCLI() *cobra.Command { rootCmd.PersistentFlags().String("loki", "", "loki host: http://loki:3100") rootCmd.PersistentFlags().String("loki-minimum-priority", "", "Minimum Priority to send Results to Loki (info < warning < error)") - rootCmd.PersistentFlags().Bool("loki-skip-exising-on-startup", false, "Skip Results created before PolicyReporter started. Prevent duplicated sending after new deployment") + rootCmd.PersistentFlags().Bool("loki-skip-existing-on-startup", false, "Skip Results created before PolicyReporter started. Prevent duplicated sending after new deployment") flag.Parse() return rootCmd } -func LoadConfig(cmd *cobra.Command) (*config.Config, error) { +func loadConfig(cmd *cobra.Command) (*config.Config, error) { v := viper.New() v.SetDefault("namespace", "policy-reporter") @@ -100,7 +91,7 @@ func LoadConfig(cmd *cobra.Command) (*config.Config, error) { if flag := cmd.Flags().Lookup("loki-minimum-priority"); flag != nil { v.BindPFlag("loki.minimumPriority", flag) } - if flag := cmd.Flags().Lookup("loki-skip-exising-on-startup"); flag != nil { + if flag := cmd.Flags().Lookup("loki-skip-existing-on-startup"); flag != nil { v.BindPFlag("loki.skipExistingOnStartup", flag) } diff --git a/pkg/config/config.go b/pkg/config/config.go index d3b7c86b..08ed70a1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,5 +1,6 @@ package config +// Config of the PolicyReporter type Config struct { Loki struct { Host string `mapstructure:"host"` diff --git a/pkg/config/resolver.go b/pkg/config/resolver.go index b099253a..2220d3e1 100644 --- a/pkg/config/resolver.go +++ b/pkg/config/resolver.go @@ -2,6 +2,7 @@ package config import ( "context" + "net/http" "time" "github.com/fjogeleit/policy-reporter/pkg/kubernetes" @@ -18,10 +19,12 @@ var ( clusterPolicyReportMetrics metrics.Metrics ) +// Resolver manages dependencies type Resolver struct { config *Config } +// PolicyReportClient resolver method func (r *Resolver) PolicyReportClient() (report.Client, error) { if kubeClient != nil { return kubeClient, nil @@ -39,6 +42,7 @@ func (r *Resolver) PolicyReportClient() (report.Client, error) { return client, err } +// LokiClient resolver method func (r *Resolver) LokiClient() target.Client { if lokiClient != nil { return lokiClient @@ -51,11 +55,13 @@ func (r *Resolver) LokiClient() target.Client { lokiClient = loki.NewClient( r.config.Loki.Host, r.config.Loki.MinimumPriority, + &http.Client{}, ) return lokiClient } +// PolicyReportMetrics resolver method func (r *Resolver) PolicyReportMetrics() (metrics.Metrics, error) { if policyReportMetrics != nil { return policyReportMetrics, nil @@ -71,6 +77,7 @@ func (r *Resolver) PolicyReportMetrics() (metrics.Metrics, error) { return policyReportMetrics, nil } +// ClusterPolicyReportMetrics resolver method func (r *Resolver) ClusterPolicyReportMetrics() (metrics.Metrics, error) { if clusterPolicyReportMetrics != nil { return clusterPolicyReportMetrics, nil @@ -86,6 +93,15 @@ func (r *Resolver) ClusterPolicyReportMetrics() (metrics.Metrics, error) { return clusterPolicyReportMetrics, nil } +// Reset all cached dependencies +func (r *Resolver) Reset() { + kubeClient = nil + lokiClient = nil + policyReportMetrics = nil + clusterPolicyReportMetrics = nil +} + +// NewResolver constructor function func NewResolver(config *Config) Resolver { return Resolver{config} } diff --git a/pkg/config/resolver_test.go b/pkg/config/resolver_test.go new file mode 100644 index 00000000..e16bb5ef --- /dev/null +++ b/pkg/config/resolver_test.go @@ -0,0 +1,54 @@ +package config_test + +import ( + "testing" + + "github.com/fjogeleit/policy-reporter/pkg/config" +) + +var testConfig = &config.Config{ + Loki: struct { + Host string "mapstructure:\"host\"" + SkipExisting bool "mapstructure:\"skipExistingOnStartup\"" + MinimumPriority string "mapstructure:\"minimumPriority\"" + }{ + Host: "http://localhost:3100", + SkipExisting: true, + MinimumPriority: "debug", + }, +} + +func Test_ResolveLokiClient(t *testing.T) { + resolver := config.NewResolver(testConfig) + + client := resolver.LokiClient() + if client == nil { + t.Error("Expected Client, got nil") + } + + client2 := resolver.LokiClient() + if client != client2 { + t.Error("Error: Should reuse first instance") + } +} + +func Test_ResolveLokiClientWithoutHost(t *testing.T) { + config2 := &config.Config{ + Loki: struct { + Host string "mapstructure:\"host\"" + SkipExisting bool "mapstructure:\"skipExistingOnStartup\"" + MinimumPriority string "mapstructure:\"minimumPriority\"" + }{ + 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") + } +} diff --git a/pkg/kubernetes/config_client.go b/pkg/kubernetes/config_client.go index 495a5e65..8344a1d0 100644 --- a/pkg/kubernetes/config_client.go +++ b/pkg/kubernetes/config_client.go @@ -11,11 +11,15 @@ import ( "k8s.io/client-go/tools/clientcmd" ) +// CoreClient provides simplified APIs for ConfigMap Resources type CoreClient interface { + // GetConfig return a single ConfigMap by name if exist GetConfig(ctx context.Context, name string) (*apiv1.ConfigMap, error) + // WatchConfigs calls its ConfigMapCallback whenever a ConfigMap was added, modified or deleted WatchConfigs(ctx context.Context, cb ConfigMapCallback) error } +// ConfigMapCallback is used by WatchConfigs type ConfigMapCallback = func(watch.EventType, *apiv1.ConfigMap) type coreClient struct { @@ -41,6 +45,7 @@ func (c coreClient) WatchConfigs(ctx context.Context, cb ConfigMapCallback) erro } } +// NewCoreClient creates a new CoreClient with the provided kubeconfig or InCluster configuration if kubeconfig is empty func NewCoreClient(kubeconfig, namespace string) (CoreClient, error) { var config *rest.Config var err error diff --git a/pkg/kubernetes/mapper.go b/pkg/kubernetes/mapper.go new file mode 100644 index 00000000..5c8464de --- /dev/null +++ b/pkg/kubernetes/mapper.go @@ -0,0 +1,166 @@ +package kubernetes + +import ( + "errors" + "time" + + "github.com/fjogeleit/policy-reporter/pkg/report" +) + +// Mapper converts maps into report structs +type Mapper interface { + // MapPolicyReport maps a map into a PolicyReport + MapPolicyReport(reportMap map[string]interface{}) report.PolicyReport + // MapClusterPolicyReport maps a map into a ClusterPolicyReport + MapClusterPolicyReport(reportMap map[string]interface{}) report.ClusterPolicyReport + // SetPriorityMap updates the policy/status to priority mapping + SetPriorityMap(map[string]string) +} + +type mapper struct { + priorityMap map[string]string +} + +func (m *mapper) MapPolicyReport(reportMap map[string]interface{}) report.PolicyReport { + summary := report.Summary{} + + if s, ok := reportMap["summary"].(map[string]interface{}); ok { + summary.Pass = int(s["pass"].(int64)) + summary.Skip = int(s["skip"].(int64)) + summary.Warn = int(s["warn"].(int64)) + summary.Error = int(s["error"].(int64)) + summary.Fail = int(s["fail"].(int64)) + } + + r := report.PolicyReport{ + Name: reportMap["metadata"].(map[string]interface{})["name"].(string), + Namespace: reportMap["metadata"].(map[string]interface{})["namespace"].(string), + Summary: summary, + Results: make(map[string]report.Result), + } + + creationTimestamp, err := m.mapCreationTime(reportMap) + if err == nil { + r.CreationTimestamp = creationTimestamp + } else { + r.CreationTimestamp = time.Now() + } + + if rs, ok := reportMap["results"].([]interface{}); ok { + for _, resultItem := range rs { + res := m.mapResult(resultItem.(map[string]interface{})) + r.Results[res.GetIdentifier()] = res + } + } + + return r +} + +func (m *mapper) MapClusterPolicyReport(reportMap map[string]interface{}) report.ClusterPolicyReport { + summary := report.Summary{} + + if s, ok := reportMap["summary"].(map[string]interface{}); ok { + summary.Pass = int(s["pass"].(int64)) + summary.Skip = int(s["skip"].(int64)) + summary.Warn = int(s["warn"].(int64)) + summary.Error = int(s["error"].(int64)) + summary.Fail = int(s["fail"].(int64)) + } + + r := report.ClusterPolicyReport{ + Name: reportMap["metadata"].(map[string]interface{})["name"].(string), + Summary: summary, + Results: make(map[string]report.Result), + } + + creationTimestamp, err := m.mapCreationTime(reportMap) + if err == nil { + r.CreationTimestamp = creationTimestamp + } else { + r.CreationTimestamp = time.Now() + } + + if rs, ok := reportMap["results"].([]interface{}); ok { + for _, resultItem := range rs { + res := m.mapResult(resultItem.(map[string]interface{})) + r.Results[res.GetIdentifier()] = res + } + } + + return r +} + +func (m *mapper) SetPriorityMap(priorityMap map[string]string) { + m.priorityMap = priorityMap +} + +func (m *mapper) mapCreationTime(result map[string]interface{}) (time.Time, error) { + if metadata, ok := result["metadata"].(map[string]interface{}); ok { + if created, ok2 := metadata["creationTimestamp"].(string); ok2 { + return time.Parse("2006-01-02T15:04:05Z", created) + } + + return time.Time{}, errors.New("No creationTimestamp provided") + } + + return time.Time{}, errors.New("No metadata provided") +} + +func (m *mapper) mapResult(result map[string]interface{}) report.Result { + var resources []report.Resource + + if ress, ok := result["resources"].([]interface{}); ok { + for _, res := range ress { + if resMap, ok := res.(map[string]interface{}); ok { + r := report.Resource{ + APIVersion: resMap["apiVersion"].(string), + Kind: resMap["kind"].(string), + Name: resMap["name"].(string), + UID: resMap["uid"].(string), + } + + if ns, ok := resMap["namespace"]; ok { + r.Namespace = ns.(string) + } + + resources = append(resources, r) + } + } + } + + status := result["status"].(report.Status) + + r := report.Result{ + Message: result["message"].(string), + Policy: result["policy"].(string), + Status: status, + Scored: result["scored"].(bool), + Priority: report.PriorityFromStatus(status), + Resources: resources, + } + + if r.Status == report.Error || r.Status == report.Fail { + if priority, ok := m.priorityMap[r.Policy]; ok { + r.Priority = report.NewPriority(priority) + } + } + + if rule, ok := result["rule"]; ok { + r.Rule = rule.(string) + } + + if category, ok := result["category"]; ok { + r.Category = category.(string) + } + + if severity, ok := result["severity"]; ok { + r.Severity = severity.(report.Severity) + } + + return r +} + +// NewMapper creates an new Mapper instance +func NewMapper(priorityMap map[string]string) Mapper { + return &mapper{priorityMap} +} diff --git a/pkg/kubernetes/mapper_test.go b/pkg/kubernetes/mapper_test.go new file mode 100644 index 00000000..95cae437 --- /dev/null +++ b/pkg/kubernetes/mapper_test.go @@ -0,0 +1,292 @@ +package kubernetes_test + +import ( + "testing" + + "github.com/fjogeleit/policy-reporter/pkg/kubernetes" + "github.com/fjogeleit/policy-reporter/pkg/report" +) + +var policyMap = map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "policy-report", + "namespace": "test", + "creationTimestamp": "2021-02-23T15:00:00Z", + }, + "summary": map[string]interface{}{ + "pass": int64(1), + "skip": int64(2), + "warn": int64(3), + "fail": int64(4), + "error": int64(5), + }, + "results": []interface{}{ + map[string]interface{}{ + "message": "message", + "status": "fail", + "scored": true, + "policy": "required-label", + "rule": "app-label-required", + "category": "test", + "severity": "low", + "resources": []interface{}{ + map[string]interface{}{ + "apiVersion": "v1", + "kind": "Deployment", + "name": "nginx", + "namespace": "test", + "uid": "dfd57c50-f30c-4729-b63f-b1954d8988d1", + }, + }, + }, + map[string]interface{}{ + "message": "message 2", + "status": "fail", + "scored": true, + "policy": "priority-test", + "resources": []interface{}{}, + }, + }, +} + +var minPolicyMap = map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "policy-report", + "namespace": "test", + }, + "results": []interface{}{}, +} + +var clusterPolicyMap = map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "clusterpolicy-report", + "creationTimestamp": "2021-02-23T15:00:00Z", + }, + "summary": map[string]interface{}{ + "pass": int64(1), + "skip": int64(2), + "warn": int64(3), + "fail": int64(4), + "error": int64(5), + }, + "results": []interface{}{ + map[string]interface{}{ + "message": "message", + "status": "fail", + "scored": true, + "policy": "required-label", + "rule": "app-label-required", + "category": "test", + "severity": "low", + "resources": []interface{}{ + map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "name": "policy-reporter", + "uid": "dfd57c50-f30c-4729-b63f-b1954d8988d1", + }, + }, + }, + }, +} + +var minClusterPolicyMap = map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "clusterpolicy-report", + }, + "results": []interface{}{}, +} + +var priorityMap = map[string]string{ + "priority-test": "warning", +} + +var mapper = kubernetes.NewMapper(priorityMap) + +func Test_MapPolicyReport(t *testing.T) { + preport := mapper.MapPolicyReport(policyMap) + + if preport.Name != "policy-report" { + t.Errorf("Expected Name 'policy-report' (acutal %s)", preport.Name) + } + if preport.Namespace != "test" { + t.Errorf("Expected Name 'test' (acutal %s)", preport.Namespace) + } + if preport.Summary.Pass != 1 { + t.Errorf("Unexpected Summary.Pass value %d (expected 1)", preport.Summary.Pass) + } + if preport.Summary.Skip != 2 { + t.Errorf("Unexpected Summary.Skip value %d (expected 2)", preport.Summary.Skip) + } + if preport.Summary.Warn != 3 { + t.Errorf("Unexpected Summary.Warn value %d (expected 3)", preport.Summary.Warn) + } + if preport.Summary.Fail != 4 { + t.Errorf("Unexpected Summary.Fail value %d (expected 4)", preport.Summary.Fail) + } + if preport.Summary.Error != 5 { + t.Errorf("Unexpected Summary.Error value %d (expected 5)", preport.Summary.Error) + } + + result1, ok := preport.Results["required-label__app-label-required__fail__dfd57c50-f30c-4729-b63f-b1954d8988d1"] + if !ok { + t.Error("Expected result not found") + } + + if result1.Message != "message" { + t.Errorf("Expected Message 'message' (acutal %s)", result1.Message) + } + if result1.Status != report.Fail { + t.Errorf("Expected Message '%s' (acutal %s)", report.Fail, result1.Status) + } + if result1.Priority != report.ErrorPriority { + t.Errorf("Expected Priority '%d' (acutal %d)", report.ErrorPriority, result1.Priority) + } + if !result1.Scored { + t.Errorf("Expected Scored to be true") + } + if result1.Policy != "required-label" { + t.Errorf("Expected Policy 'required-label' (acutal %s)", result1.Policy) + } + if result1.Rule != "app-label-required" { + t.Errorf("Expected Rule 'app-label-required' (acutal %s)", result1.Rule) + } + if result1.Category != "test" { + t.Errorf("Expected Category 'test' (acutal %s)", result1.Category) + } + if result1.Severity != report.Low { + t.Errorf("Expected Severity '%s' (acutal %s)", report.Low, result1.Severity) + } + + resource := result1.Resources[0] + if resource.APIVersion != "v1" { + t.Errorf("Expected Resource.APIVersion 'v1' (acutal %s)", resource.APIVersion) + } + if resource.Kind != "Deployment" { + t.Errorf("Expected Resource.Kind 'Deployment' (acutal %s)", resource.Kind) + } + if resource.Name != "nginx" { + t.Errorf("Expected Resource.Name 'nginx' (acutal %s)", resource.Name) + } + if resource.Namespace != "test" { + t.Errorf("Expected Resource.Namespace 'test' (acutal %s)", resource.Namespace) + } + if resource.UID != "dfd57c50-f30c-4729-b63f-b1954d8988d1" { + t.Errorf("Expected Resource.Namespace 'dfd57c50-f30c-4729-b63f-b1954d8988d1' (acutal %s)", resource.UID) + } + + result2, ok := preport.Results["priority-test____fail"] + if !ok { + t.Error("Expected result not found") + } + + if result2.Message != "message 2" { + t.Errorf("Expected Message 'message' (acutal %s)", result1.Message) + } + if result2.Status != report.Fail { + t.Errorf("Expected Message '%s' (acutal %s)", report.Fail, result2.Status) + } + if result2.Priority != report.WarningPriority { + t.Errorf("Expected Priority '%d' (acutal %s)", report.WarningPriority, result2.Priority) + } + if !result2.Scored { + t.Errorf("Expected Scored to be true") + } + if result2.Policy != "priority-test" { + t.Errorf("Expected Policy 'priority-test' (acutal %s)", result2.Policy) + } + if result2.Rule != "" { + t.Errorf("Expected Rule to be empty (acutal %s)", result2.Rule) + } + if result2.Category != "" { + t.Errorf("Expected Category to be empty (acutal %s)", result2.Category) + } + if result2.Severity != "" { + t.Errorf("Expected Severity to be empty (acutal %s)", report.Low) + } +} + +func Test_MapMinPolicyReport(t *testing.T) { + report := mapper.MapPolicyReport(minPolicyMap) + + if report.Name != "policy-report" { + t.Errorf("Expected Name 'policy-report' (acutal %s)", report.Name) + } + if report.Namespace != "test" { + t.Errorf("Expected Name 'test' (acutal %s)", report.Namespace) + } + if report.Summary.Pass != 0 { + t.Errorf("Unexpected Summary.Pass value %d (expected 0)", report.Summary.Pass) + } + if report.Summary.Skip != 0 { + t.Errorf("Unexpected Summary.Skip value %d (expected 0)", report.Summary.Skip) + } + if report.Summary.Warn != 0 { + t.Errorf("Unexpected Summary.Warn value %d (expected 0)", report.Summary.Warn) + } + if report.Summary.Fail != 0 { + t.Errorf("Unexpected Summary.Fail value %d (expected 0)", report.Summary.Fail) + } + if report.Summary.Error != 0 { + t.Errorf("Unexpected Summary.Error value %d (expected 0)", report.Summary.Error) + } +} + +func Test_MapClusterPolicyReport(t *testing.T) { + report := mapper.MapClusterPolicyReport(clusterPolicyMap) + + if report.Name != "clusterpolicy-report" { + t.Errorf("Expected Name 'clusterpolicy-report' (acutal %s)", report.Name) + } + if report.Summary.Pass != 1 { + t.Errorf("Unexpected Summary.Pass value %d (expected 1)", report.Summary.Pass) + } + if report.Summary.Skip != 2 { + t.Errorf("Unexpected Summary.Skip value %d (expected 2)", report.Summary.Skip) + } + if report.Summary.Warn != 3 { + t.Errorf("Unexpected Summary.Warn value %d (expected 3)", report.Summary.Warn) + } + if report.Summary.Fail != 4 { + t.Errorf("Unexpected Summary.Fail value %d (expected 4)", report.Summary.Fail) + } + if report.Summary.Error != 5 { + t.Errorf("Unexpected Summary.Error value %d (expected 5)", report.Summary.Error) + } +} + +func Test_MapMinClusterPolicyReport(t *testing.T) { + report := mapper.MapClusterPolicyReport(minClusterPolicyMap) + + if report.Name != "clusterpolicy-report" { + t.Errorf("Expected Name 'clusterpolicy-report' (acutal %s)", report.Name) + } + if report.Summary.Pass != 0 { + t.Errorf("Unexpected Summary.Pass value %d (expected 0)", report.Summary.Pass) + } + if report.Summary.Skip != 0 { + t.Errorf("Unexpected Summary.Skip value %d (expected 0)", report.Summary.Skip) + } + if report.Summary.Warn != 0 { + t.Errorf("Unexpected Summary.Warn value %d (expected 0)", report.Summary.Warn) + } + if report.Summary.Fail != 0 { + t.Errorf("Unexpected Summary.Fail value %d (expected 0)", report.Summary.Fail) + } + if report.Summary.Error != 0 { + t.Errorf("Unexpected Summary.Error value %d (expected 0)", report.Summary.Error) + } +} + +func Test_MapperSetPriorityMap(t *testing.T) { + mapper := kubernetes.NewMapper(make(map[string]string)) + mapper.SetPriorityMap(map[string]string{"required-label": "debug"}) + + preport := mapper.MapPolicyReport(policyMap) + + result1 := preport.Results["required-label__app-label-required__fail__dfd57c50-f30c-4729-b63f-b1954d8988d1"] + + if result1.Priority != report.DebugPriority { + t.Errorf("Expected Policy '%d' (acutal %d)", report.DebugPriority, result1.Priority) + } +} diff --git a/pkg/kubernetes/report_client.go b/pkg/kubernetes/report_client.go index 0c4c0d52..4f29f3b5 100644 --- a/pkg/kubernetes/report_client.go +++ b/pkg/kubernetes/report_client.go @@ -2,7 +2,6 @@ package kubernetes import ( "context" - "errors" "log" "time" @@ -32,7 +31,7 @@ type policyReportClient struct { coreClient CoreClient policyCache map[string]report.PolicyReport clusterPolicyCache map[string]report.ClusterPolicyReport - priorityMap map[string]string + mapper Mapper startUp time.Time } @@ -46,7 +45,7 @@ func (c *policyReportClient) FetchPolicyReports() ([]report.PolicyReport, error) } for _, item := range result.Items { - reports = append(reports, c.mapPolicyReport(item.Object)) + reports = append(reports, c.mapper.MapPolicyReport(item.Object)) } return reports, nil @@ -61,7 +60,7 @@ func (c *policyReportClient) WatchClusterPolicyReports(cb report.WatchClusterPol for result := range result.ResultChan() { if item, ok := result.Object.(*unstructured.Unstructured); ok { - cb(result.Type, c.mapClusterPolicyReport(item.Object)) + cb(result.Type, c.mapper.MapClusterPolicyReport(item.Object)) } } } @@ -76,7 +75,7 @@ func (c *policyReportClient) WatchPolicyReports(cb report.WatchPolicyReportCallb for result := range result.ResultChan() { if item, ok := result.Object.(*unstructured.Unstructured); ok { - cb(result.Type, c.mapPolicyReport(item.Object)) + cb(result.Type, c.mapper.MapPolicyReport(item.Object)) } } } @@ -100,7 +99,7 @@ func (c *policyReportClient) WatchRuleValidation(cb report.WatchPolicyResultCall c.policyCache[pr.GetIdentifier()] = pr case watch.Modified: - diff := pr.GetNewValidation(c.policyCache[pr.GetIdentifier()]) + diff := pr.GetNewResults(c.policyCache[pr.GetIdentifier()]) for _, result := range diff { cb(result) } @@ -127,7 +126,7 @@ func (c *policyReportClient) WatchRuleValidation(cb report.WatchPolicyResultCall c.clusterPolicyCache[cpr.GetIdentifier()] = cpr case watch.Modified: - diff := cpr.GetNewValidation(c.clusterPolicyCache[cpr.GetIdentifier()]) + diff := cpr.GetNewResults(c.clusterPolicyCache[cpr.GetIdentifier()]) for _, result := range diff { cb(result) } @@ -149,7 +148,7 @@ func (c *policyReportClient) fetchPriorities(ctx context.Context) error { } if cm != nil { - c.priorityMap = cm.Data + c.mapper.SetPriorityMap(cm.Data) log.Println("[INFO] Priorities loaded") } @@ -164,11 +163,11 @@ func (c *policyReportClient) syncPriorities(ctx context.Context) error { switch e { case watch.Added: - c.priorityMap = cm.Data + c.mapper.SetPriorityMap(cm.Data) case watch.Modified: - c.priorityMap = cm.Data + c.mapper.SetPriorityMap(cm.Data) case watch.Deleted: - c.priorityMap = map[string]string{} + c.mapper.SetPriorityMap(map[string]string{}) } log.Println("[INFO] Priorities synchronized") @@ -181,134 +180,7 @@ func (c *policyReportClient) syncPriorities(ctx context.Context) error { return err } -func (c *policyReportClient) mapPolicyReport(reportMap map[string]interface{}) report.PolicyReport { - summary := report.Summary{} - - if s, ok := reportMap["summary"].(map[string]interface{}); ok { - summary.Pass = int(s["pass"].(int64)) - summary.Skip = int(s["skip"].(int64)) - summary.Warn = int(s["warn"].(int64)) - summary.Error = int(s["error"].(int64)) - summary.Fail = int(s["fail"].(int64)) - } - - r := report.PolicyReport{ - Name: reportMap["metadata"].(map[string]interface{})["name"].(string), - Namespace: reportMap["metadata"].(map[string]interface{})["namespace"].(string), - Summary: summary, - Results: make(map[string]report.Result), - } - - if rs, ok := reportMap["results"].([]interface{}); ok { - for _, resultItem := range rs { - res := c.mapResult(resultItem.(map[string]interface{})) - r.Results[res.GetIdentifier()] = res - } - } - - return r -} - -func (c *policyReportClient) mapClusterPolicyReport(reportMap map[string]interface{}) report.ClusterPolicyReport { - summary := report.Summary{} - - if s, ok := reportMap["summary"].(map[string]interface{}); ok { - summary.Pass = int(s["pass"].(int64)) - summary.Skip = int(s["skip"].(int64)) - summary.Warn = int(s["warn"].(int64)) - summary.Error = int(s["error"].(int64)) - summary.Fail = int(s["fail"].(int64)) - } - - r := report.ClusterPolicyReport{ - Name: reportMap["metadata"].(map[string]interface{})["name"].(string), - Summary: summary, - Results: make(map[string]report.Result), - } - - creationTimestamp, err := c.mapCreationTime(reportMap) - if err == nil { - r.CreationTimestamp = creationTimestamp - } else { - r.CreationTimestamp = time.Now() - } - - if rs, ok := reportMap["results"].([]interface{}); ok { - for _, resultItem := range rs { - res := c.mapResult(resultItem.(map[string]interface{})) - r.Results[res.GetIdentifier()] = res - } - } - - return r -} - -func (c *policyReportClient) mapCreationTime(result map[string]interface{}) (time.Time, error) { - if metadata, ok := result["metadata"].(map[string]interface{}); ok { - if created, ok2 := metadata["creationTimestamp"].(string); ok2 { - return time.Parse("2006-01-02T15:04:05Z", created) - } - - return time.Time{}, errors.New("No creationTimestamp provided") - } - - return time.Time{}, errors.New("No metadata provided") -} - -func (c *policyReportClient) mapResult(result map[string]interface{}) report.Result { - var resources []report.Resource - - if ress, ok := result["resources"].([]interface{}); ok { - for _, res := range ress { - if resMap, ok := res.(map[string]interface{}); ok { - r := report.Resource{ - APIVersion: resMap["apiVersion"].(string), - Kind: resMap["kind"].(string), - Name: resMap["name"].(string), - UID: resMap["uid"].(string), - } - - if ns, ok := result["namespace"]; ok { - r.Namespace = ns.(string) - } - - resources = append(resources, r) - } - } - } - - status := result["status"].(report.Status) - - r := report.Result{ - Message: result["message"].(string), - Policy: result["policy"].(string), - Status: status, - Scored: result["scored"].(bool), - Priority: report.PriorityFromStatus(status), - Resources: resources, - } - - if r.Status == report.Error || r.Status == report.Fail { - if priority, ok := c.priorityMap[r.Policy]; ok { - r.Priority = report.NewPriority(priority) - } - } - - if rule, ok := result["rule"]; ok { - r.Rule = rule.(string) - } - - if category, ok := result["category"]; ok { - r.Category = category.(string) - } - - if severity, ok := result["severity"]; ok { - r.Severity = severity.(report.Severity) - } - - return r -} - +// NewPolicyReportClient creates a new ReportClient based on the kubernetes go-client func NewPolicyReportClient(ctx context.Context, kubeconfig, namespace string, startUp time.Time) (report.Client, error) { var config *rest.Config var err error @@ -337,7 +209,7 @@ func NewPolicyReportClient(ctx context.Context, kubeconfig, namespace string, st coreClient: coreClient, policyCache: make(map[string]report.PolicyReport), clusterPolicyCache: make(map[string]report.ClusterPolicyReport), - priorityMap: make(map[string]string), + mapper: NewMapper(make(map[string]string)), startUp: startUp, } diff --git a/pkg/metrics/cluster_policy_report.go b/pkg/metrics/cluster_policy_report.go index ff1ace7c..106b870f 100644 --- a/pkg/metrics/cluster_policy_report.go +++ b/pkg/metrics/cluster_policy_report.go @@ -9,6 +9,7 @@ import ( "k8s.io/apimachinery/pkg/watch" ) +// ClusterPolicyReportMetrics creates ClusterPolicy Metrics type ClusterPolicyReportMetrics struct { client report.Client cache map[string]report.ClusterPolicyReport @@ -33,6 +34,7 @@ func (m ClusterPolicyReportMetrics) removeCachedReport(i string) { m.rwmutex.Unlock() } +// GenerateMetrics for ClusterPolicyReport Summaries and PolicyResults func (m ClusterPolicyReportMetrics) GenerateMetrics() error { policyGauge := promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "cluster_policy_report_summary", @@ -132,6 +134,7 @@ func updateClusterPolicyGauge(policyGauge *prometheus.GaugeVec, report report.Cl Set(float64(report.Summary.Skip)) } +// NewClusterPolicyMetrics creates a new ClusterPolicyReportMetrics pointer func NewClusterPolicyMetrics(client report.Client) *ClusterPolicyReportMetrics { return &ClusterPolicyReportMetrics{ client: client, diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 9bb9468e..a6364bfa 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -1,5 +1,7 @@ package metrics +// Metrics interface for Prometheus Metrics used for the Resolver type Metrics interface { + // GenerateMetrics for Prometheus GenerateMetrics() error } diff --git a/pkg/metrics/policy_report.go b/pkg/metrics/policy_report.go index 2fb719f6..a51599ce 100644 --- a/pkg/metrics/policy_report.go +++ b/pkg/metrics/policy_report.go @@ -9,6 +9,7 @@ import ( "k8s.io/apimachinery/pkg/watch" ) +// PolicyReportMetrics creates ClusterPolicy Metrics type PolicyReportMetrics struct { client report.Client cache map[string]report.PolicyReport @@ -33,6 +34,7 @@ func (m PolicyReportMetrics) removeCachedReport(i string) { m.rwmutex.Unlock() } +// GenerateMetrics for PolicyReport Summaries and PolicyResults func (m PolicyReportMetrics) GenerateMetrics() error { policyGauge := promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "policy_report_summary", @@ -146,6 +148,7 @@ func updatePolicyGauge(policyGauge *prometheus.GaugeVec, report report.PolicyRep Set(float64(report.Summary.Skip)) } +// NewPolicyReportMetrics creates a new PolicyReportMetrics pointer func NewPolicyReportMetrics(client report.Client) *PolicyReportMetrics { return &PolicyReportMetrics{ client: client, diff --git a/pkg/report/client.go b/pkg/report/client.go index d3d15ea2..970986be 100644 --- a/pkg/report/client.go +++ b/pkg/report/client.go @@ -2,13 +2,23 @@ package report import "k8s.io/apimachinery/pkg/watch" +// WatchPolicyReportCallback is called whenver a new PolicyReport comes in type WatchPolicyReportCallback = func(watch.EventType, PolicyReport) + +// WatchClusterPolicyReportCallback is called whenver a new ClusterPolicyReport comes in type WatchClusterPolicyReportCallback = func(watch.EventType, ClusterPolicyReport) + +// WatchPolicyResultCallback is called whenver a new PolicyResult comes in type WatchPolicyResultCallback = func(Result) +// Client interface for interacting with the Kubernetes API type Client interface { + // FetchPolicyReports from the unterlying API FetchPolicyReports() ([]PolicyReport, error) + // WatchPolicyReports blocking API to watch for PolicyReport changes WatchPolicyReports(WatchPolicyReportCallback) error + // WatchRuleValidation blocking API to watch for PolicyResult changes from PolicyReports and ClusterPolicyReports WatchRuleValidation(WatchPolicyResultCallback, bool) error + // WatchClusterPolicyReports blocking API to watch for ClusterPolicyReport changes WatchClusterPolicyReports(WatchClusterPolicyReportCallback) error } diff --git a/pkg/report/model.go b/pkg/report/model.go index 22093d8d..fc470d6f 100644 --- a/pkg/report/model.go +++ b/pkg/report/model.go @@ -5,9 +5,13 @@ import ( "time" ) +// Status Enum defined for PolicyReport type Status = string + +// Severity Enum defined for PolicyReport type Severity = string +// Enums for predefined values from the PolicyReport spec const ( Fail Status = "fail" Warn Status = "warn" @@ -26,6 +30,7 @@ const ( errorString = "error" ) +// Internal Priority definitions and weighting const ( DefaultPriority = iota DebugPriority @@ -34,8 +39,10 @@ const ( ErrorPriority ) +// Priority Enum for internal Result weighting type Priority int +// String maps the internal weighting of Priorities to a String representation func (p Priority) String() string { switch p { case DebugPriority: @@ -51,6 +58,7 @@ func (p Priority) String() string { } } +// PriorityFromStatus creates a Priority based on a Status func PriorityFromStatus(p Status) Priority { switch p { case Fail: @@ -66,6 +74,7 @@ func PriorityFromStatus(p Status) Priority { } } +// NewPriority creates a new Priority based an its string representation func NewPriority(p string) Priority { switch p { case debugString: @@ -81,6 +90,7 @@ func NewPriority(p string) Priority { } } +// Resource from the Kubernetes spec k8s.io/api/core/v1.ObjectReference type Resource struct { APIVersion string Kind string @@ -89,6 +99,7 @@ type Resource struct { UID string } +// Result from the PolicyReport spec wgpolicyk8s.io/v1alpha1.PolicyReportResult type Result struct { Message string Policy string @@ -101,19 +112,17 @@ type Result struct { Resources []Resource } +// GetIdentifier returns a global unique Result identifier func (r Result) GetIdentifier() string { - res := Resource{} + suffix := "" if len(r.Resources) > 0 { - res = r.Resources[0] + suffix = "__" + r.Resources[0].UID } - return fmt.Sprintf("%s__%s__%s__%s", r.Policy, r.Rule, r.Status, res.UID) -} - -type Report interface { - GetIdentifier() string + return fmt.Sprintf("%s__%s__%s%s", r.Policy, r.Rule, r.Status, suffix) } +// Summary from the PolicyReport spec wgpolicyk8s.io/v1alpha1.PolicyReportSummary type Summary struct { Pass int Skip int @@ -122,6 +131,7 @@ type Summary struct { Fail int } +// PolicyReport from the PolicyReport spec wgpolicyk8s.io/v1alpha1.PolicyReport type PolicyReport struct { Name string Namespace string @@ -130,11 +140,13 @@ type PolicyReport struct { CreationTimestamp time.Time } +// GetIdentifier returns a global unique PolicyReport identifier func (pr PolicyReport) GetIdentifier() string { return fmt.Sprintf("%s__%s", pr.Namespace, pr.Name) } -func (pr PolicyReport) GetNewValidation(or PolicyReport) []Result { +// GetNewResults filters already existing Results from the old PolicyReport and returns only the diff with new Results +func (pr PolicyReport) GetNewResults(or PolicyReport) []Result { diff := make([]Result, 0) for _, r := range pr.Results { @@ -148,6 +160,7 @@ func (pr PolicyReport) GetNewValidation(or PolicyReport) []Result { return diff } +// ClusterPolicyReport from the PolicyReport spec wgpolicyk8s.io/v1alpha1.ClusterPolicyReport type ClusterPolicyReport struct { Name string Results map[string]Result @@ -155,11 +168,13 @@ type ClusterPolicyReport struct { CreationTimestamp time.Time } +// GetIdentifier returns a global unique ClusterPolicyReport identifier func (cr ClusterPolicyReport) GetIdentifier() string { return cr.Name } -func (cr ClusterPolicyReport) GetNewValidation(cor ClusterPolicyReport) []Result { +// GetNewResults filters already existing Results from the old PolicyReport and returns only the diff with new Results +func (cr ClusterPolicyReport) GetNewResults(cor ClusterPolicyReport) []Result { diff := make([]Result, 0) for _, r := range cr.Results { diff --git a/pkg/report/model_test.go b/pkg/report/model_test.go new file mode 100644 index 00000000..d38d18b4 --- /dev/null +++ b/pkg/report/model_test.go @@ -0,0 +1,170 @@ +package report_test + +import ( + "fmt" + "testing" + "time" + + "github.com/fjogeleit/policy-reporter/pkg/report" +) + +var result1 = 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, + Category: "resources", + Scored: true, + Resources: []report.Resource{ + { + APIVersion: "v1", + Kind: "Deployment", + Name: "nginx", + Namespace: "test", + UID: "536ab69f-1b3c-4bd9-9ba4-274a56188409", + }, + }, +} + +var result2 = 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, + Category: "resources", + Scored: true, + Resources: []report.Resource{ + { + APIVersion: "v1", + Kind: "Deployment", + Name: "nginx", + Namespace: "test", + UID: "536ab69f-1b3c-4bd9-9ba4-274a56188419", + }, + }, +} + +var preport = report.PolicyReport{ + Name: "polr-test", + Namespace: "test", + Results: make(map[string]report.Result, 0), + Summary: report.Summary{}, + CreationTimestamp: time.Now(), +} + +var creport = report.ClusterPolicyReport{ + Name: "cpolr-test", + Results: make(map[string]report.Result, 0), + Summary: report.Summary{}, + CreationTimestamp: time.Now(), +} + +func Test_PolicyReport(t *testing.T) { + t.Run("Check PolicyReport.GetIdentifier", func(t *testing.T) { + expected := fmt.Sprintf("%s__%s", preport.Namespace, preport.Name) + + if preport.GetIdentifier() != expected { + t.Errorf("Expected PolicyReport.GetIdentifier() to be %s (actual: %s)", expected, preport.GetIdentifier()) + } + }) + + t.Run("Check PolicyReport.GetNewResults", func(t *testing.T) { + preport1 := preport + preport2 := preport + + preport1.Results = map[string]report.Result{result1.GetIdentifier(): result1} + preport2.Results = map[string]report.Result{result1.GetIdentifier(): result1, result2.GetIdentifier(): result2} + + diff := preport2.GetNewResults(preport1) + if len(diff) != 1 { + t.Error("Expected 1 new result in diff") + } + }) +} + +func Test_ClusterPolicyReport(t *testing.T) { + t.Run("Check ClusterPolicyReport.GetIdentifier", func(t *testing.T) { + if creport.GetIdentifier() != creport.Name { + t.Errorf("Expected ClusterPolicyReport.GetIdentifier() to be %s (actual: %s)", creport.Name, creport.GetIdentifier()) + } + }) + + t.Run("Check ClusterPolicyReport.GetNewResults", func(t *testing.T) { + creport1 := creport + creport2 := creport + + creport1.Results = map[string]report.Result{result1.GetIdentifier(): result1} + creport2.Results = map[string]report.Result{result1.GetIdentifier(): result1, result2.GetIdentifier(): result2} + + diff := creport2.GetNewResults(creport1) + if len(diff) != 1 { + t.Error("Expected 1 new result in diff") + } + }) +} + +func Test_Result(t *testing.T) { + t.Run("Check Result.GetIdentifier", func(t *testing.T) { + expected := fmt.Sprintf("%s__%s__%s__%s", result1.Policy, result1.Rule, result1.Status, result1.Resources[0].UID) + + if result1.GetIdentifier() != expected { + t.Errorf("Expected ClusterPolicyReport.GetIdentifier() to be %s (actual: %s)", expected, creport.GetIdentifier()) + } + }) +} + +func Test_Priorities(t *testing.T) { + t.Run("Priority.String", func(t *testing.T) { + if prio := report.Priority(0).String(); prio != "" { + t.Errorf("Expected Priority to be '' (actual %s)", prio) + } + if prio := report.Priority(1).String(); prio != "debug" { + t.Errorf("Expected Priority to be debug (actual %s)", prio) + } + if prio := report.Priority(2).String(); prio != "info" { + t.Errorf("Expected Priority to be debug (actual %s)", prio) + } + if prio := report.Priority(3).String(); prio != "warning" { + t.Errorf("Expected Priority to be debug (actual %s)", prio) + } + if prio := report.Priority(4).String(); prio != "error" { + t.Errorf("Expected Priority to be debug (actual %s)", prio) + } + }) + t.Run("PriorityFromStatus", func(t *testing.T) { + if prio := report.PriorityFromStatus(report.Fail); prio != report.ErrorPriority { + t.Errorf("Expected Priority to be %d (actual %d)", report.ErrorPriority, prio) + } + if prio := report.PriorityFromStatus(report.Error); prio != report.ErrorPriority { + t.Errorf("Expected Priority to be %d (actual %d)", report.ErrorPriority, prio) + } + if prio := report.PriorityFromStatus(report.Pass); prio != report.InfoPriority { + t.Errorf("Expected Priority to be %d (actual %d)", report.InfoPriority, prio) + } + if prio := report.PriorityFromStatus(report.Skip); prio != report.DefaultPriority { + t.Errorf("Expected Priority to be %d (actual %d)", report.DefaultPriority, prio) + } + if prio := report.PriorityFromStatus(report.Warn); prio != report.WarningPriority { + t.Errorf("Expected Priority to be %d (actual %d)", report.WarningPriority, prio) + } + }) + t.Run("PriorityFromStatus", func(t *testing.T) { + if prio := report.NewPriority(""); prio != report.DefaultPriority { + t.Errorf("Expected Priority to be %d (actual %d)", report.DefaultPriority, prio) + } + if prio := report.NewPriority("error"); prio != report.ErrorPriority { + t.Errorf("Expected Priority to be %d (actual %d)", report.ErrorPriority, prio) + } + if prio := report.NewPriority("warning"); prio != report.WarningPriority { + t.Errorf("Expected Priority to be %d (actual %d)", report.WarningPriority, prio) + } + if prio := report.NewPriority("info"); prio != report.InfoPriority { + t.Errorf("Expected Priority to be %d (actual %d)", report.InfoPriority, prio) + } + if prio := report.NewPriority("debug"); prio != report.DebugPriority { + t.Errorf("Expected Priority to be %d (actual %d)", report.DebugPriority, prio) + } + }) +} diff --git a/pkg/target/client.go b/pkg/target/client.go index 0c63f654..803c71c8 100644 --- a/pkg/target/client.go +++ b/pkg/target/client.go @@ -4,6 +4,8 @@ import ( "github.com/fjogeleit/policy-reporter/pkg/report" ) +// Client for a provided Target type Client interface { + // Send the given Result to the configured Target Send(result report.Result) } diff --git a/pkg/target/loki/loki.go b/pkg/target/loki/loki.go index 6a65e86c..f9df5c59 100644 --- a/pkg/target/loki/loki.go +++ b/pkg/target/loki/loki.go @@ -13,6 +13,10 @@ import ( "github.com/fjogeleit/policy-reporter/pkg/target" ) +type httpClient interface { + Do(req *http.Request) (*http.Response, error) +} + type payload struct { Streams []stream `json:"streams"` } @@ -65,13 +69,13 @@ func newLokiPayload(result report.Result) payload { return payload{Streams: []stream{ls}} } -type Client struct { +type client struct { host string minimumPriority string - client *http.Client + client httpClient } -func (l *Client) Send(result report.Result) { +func (l *client) Send(result report.Result) { if result.Priority < report.NewPriority(l.minimumPriority) { return } @@ -93,7 +97,7 @@ func (l *Client) Send(result report.Result) { resp, err := l.client.Do(req) defer func() { - if resp != nil { + if resp != nil && resp.Body != nil { resp.Body.Close() } }() @@ -111,10 +115,11 @@ func (l *Client) Send(result report.Result) { } } -func NewClient(host, minimumPriority string) target.Client { - return &Client{ +// NewClient creates a new loki.client to send Results to Loki +func NewClient(host, minimumPriority string, httpClient httpClient) target.Client { + return &client{ host + "/api/prom/push", minimumPriority, - &http.Client{}, + httpClient, } } diff --git a/pkg/target/loki/loki_test.go b/pkg/target/loki/loki_test.go new file mode 100644 index 00000000..c21e80b9 --- /dev/null +++ b/pkg/target/loki/loki_test.go @@ -0,0 +1,215 @@ +package loki_test + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/fjogeleit/policy-reporter/pkg/report" + "github.com/fjogeleit/policy-reporter/pkg/target/loki" +) + +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" { + t.Errorf("Unexpected Content-Type: %s", contentType) + } + + if agend := req.Header.Get("User-Agent"); agend != "Policy-API" { + t.Errorf("Unexpected Host: %s", agend) + } + + if url := req.URL.String(); url != "http://localhost:3100/api/prom/push" { + t.Errorf("Unexpected Host: %s", url) + } + + expectedLine := fmt.Sprintf("[%s] %s", strings.ToUpper(completeResult.Priority.String()), completeResult.Message) + labels, line := convertAndValidateBody(req, t) + if line != expectedLine { + t.Errorf("Unexpected LineContent: %s", line) + } + if !strings.Contains(labels, "policy=\""+completeResult.Policy+"\"") { + t.Error("Missing Content for Label 'policy'") + } + if !strings.Contains(labels, "status=\""+completeResult.Status+"\"") { + t.Error("Missing Content for Label 'status'") + } + if !strings.Contains(labels, "priority=\""+completeResult.Priority.String()+"\"") { + t.Error("Missing Content for Label 'priority'") + } + if !strings.Contains(labels, "source=\"policy-reporter\"") { + t.Error("Missing Content for Label 'policy-reporter'") + } + if !strings.Contains(labels, "rule=\""+completeResult.Rule+"\"") { + t.Error("Missing Content for Label 'rule'") + } + if !strings.Contains(labels, "category=\""+completeResult.Category+"\"") { + t.Error("Missing Content for Label 'category'") + } + if !strings.Contains(labels, "severity=\""+completeResult.Severity+"\"") { + t.Error("Missing Content for Label 'severity'") + } + + res := completeResult.Resources[0] + if !strings.Contains(labels, "kind=\""+res.Kind+"\"") { + t.Error("Missing Content for Label 'kind'") + } + if !strings.Contains(labels, "name=\""+res.Name+"\"") { + t.Error("Missing Content for Label 'name'") + } + if !strings.Contains(labels, "uid=\""+res.UID+"\"") { + t.Error("Missing Content for Label 'uid'") + } + if !strings.Contains(labels, "namespace=\""+res.Namespace+"\"") { + t.Error("Missing Content for Label 'namespace'") + } + } + + loki := loki.NewClient("http://localhost:3100", "", testClient{callback, 200}) + loki.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" { + t.Errorf("Unexpected Content-Type: %s", contentType) + } + + if agend := req.Header.Get("User-Agent"); agend != "Policy-API" { + t.Errorf("Unexpected Host: %s", agend) + } + + if url := req.URL.String(); url != "http://localhost:3100/api/prom/push" { + t.Errorf("Unexpected Host: %s", url) + } + + expectedLine := fmt.Sprintf("[%s] %s", strings.ToUpper(minimalResult.Priority.String()), minimalResult.Message) + labels, line := convertAndValidateBody(req, t) + if line != expectedLine { + t.Errorf("Unexpected LineContent: %s", line) + } + if !strings.Contains(labels, "policy=\""+minimalResult.Policy+"\"") { + t.Error("Missing Content for Label 'policy'") + } + if !strings.Contains(labels, "status=\""+minimalResult.Status+"\"") { + t.Error("Missing Content for Label 'status'") + } + if !strings.Contains(labels, "priority=\""+minimalResult.Priority.String()+"\"") { + t.Error("Missing Content for Label 'priority'") + } + if !strings.Contains(labels, "source=\"policy-reporter\"") { + t.Error("Missing Content for Label 'policy-reporter'") + } + if strings.Contains(labels, "rule") { + t.Error("Unexpected Label 'rule'") + } + if strings.Contains(labels, "category") { + t.Error("Unexpected Label 'category'") + } + if strings.Contains(labels, "severity") { + t.Error("Unexpected 'severity'") + } + if strings.Contains(labels, "kind") { + t.Error("Unexpected Label 'kind'") + } + if strings.Contains(labels, "name") { + t.Error("Unexpected 'name'") + } + if strings.Contains(labels, "uid") { + t.Error("Unexpected 'uid'") + } + if strings.Contains(labels, "namespace") { + t.Error("Unexpected 'namespace'") + } + } + + loki := loki.NewClient("http://localhost:3100", "", testClient{callback, 200}) + loki.Send(minimalResult) + }) +} + +func convertAndValidateBody(req *http.Request, t *testing.T) (string, string) { + payload := make(map[string]interface{}) + + err := json.NewDecoder(req.Body).Decode(&payload) + if err != nil { + t.Fatal(err) + } + + streamsContent, ok := payload["streams"] + if !ok { + t.Errorf("Expected payload key 'streams' is missing") + } + + streams := streamsContent.([]interface{}) + if len(streams) != 1 { + t.Errorf("Expected one streams entry") + } + + firstStream := streams[0].(map[string]interface{}) + entriesContent, ok := firstStream["entries"] + if !ok { + t.Errorf("Expected stream key 'entries' is missing") + } + labels, ok := firstStream["labels"] + if !ok { + t.Errorf("Expected stream key 'labels' is missing") + } + + entryContent := entriesContent.([]interface{})[0] + entry := entryContent.(map[string]interface{}) + _, ok = entry["ts"] + if !ok { + t.Errorf("Expected entry key 'ts' is missing") + } + line, ok := entry["line"] + if !ok { + t.Errorf("Expected entry key 'line' is missing") + } + + return labels.(string), line.(string) +}