From 836d6fe4368afed03b25a81a3ddc8b7cedf4a9f7 Mon Sep 17 00:00:00 2001 From: Frank Jogeleit Date: Sat, 4 May 2024 10:04:27 +0200 Subject: [PATCH] API to render Violations Report (#429) * API to render Violations Report Signed-off-by: Frank Jogeleit --- .gitignore | 3 +- .../policy-reporter/templates/deployment.yaml | 1 + cmd/run.go | 3 +- pkg/api/server.go | 9 +- pkg/api/server_test.go | 4 +- pkg/api/v1/handler_test.go | 17 ++ pkg/api/v1/html_report.go | 146 ++++++++++++++++++ pkg/config/config.go | 14 +- pkg/config/load.go | 2 +- pkg/config/load_test.go | 4 +- pkg/config/resolver.go | 7 +- pkg/config/resolver_test.go | 6 +- templates/violations.html | 2 +- 13 files changed, 194 insertions(+), 24 deletions(-) create mode 100644 pkg/api/v1/html_report.go diff --git a/.gitignore b/.gitignore index 00aca72a..a7fb23a6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,7 @@ /config.yaml build /test.yaml -**/test.db -sqlite-database*.db +*.db values*.yaml coverage.out* heap* diff --git a/charts/policy-reporter/templates/deployment.yaml b/charts/policy-reporter/templates/deployment.yaml index 140d4e7c..4dd740ab 100644 --- a/charts/policy-reporter/templates/deployment.yaml +++ b/charts/policy-reporter/templates/deployment.yaml @@ -67,6 +67,7 @@ spec: - --rest-enabled={{ or .Values.rest.enabled .Values.ui.enabled }} - --profile={{ .Values.profiling.enabled }} - --lease-name={{ include "policyreporter.fullname" . }} + - --template-dir=/app/templates ports: - name: {{ .Values.port.name }} containerPort: {{ .Values.port.number }} diff --git a/cmd/run.go b/cmd/run.go index 9007d26f..a039bec2 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -76,7 +76,7 @@ func newRunCMD(version string) *cobra.Command { } logger.Info("REST api enabled") - server.RegisterV1Handler(store) + server.RegisterV1Handler(store, resolver.ViolationsReporter()) } if c.Metrics.Enabled { @@ -175,6 +175,7 @@ func newRunCMD(version string) *cobra.Command { cmd.PersistentFlags().Bool("profile", false, "Enable application profiling with pprof") cmd.PersistentFlags().String("lease-name", "policy-reporter", "name of the LeaseLock") cmd.PersistentFlags().String("pod-name", "policy-reporter", "name of the pod, used for leaderelection") + cmd.PersistentFlags().StringP("template-dir", "t", "./templates", "template directory") cmd.PersistentFlags().Int("worker", 5, "amount of queue worker") cmd.PersistentFlags().Float32("qps", 20, "K8s RESTClient QPS") cmd.PersistentFlags().Int("burst", 50, "K8s RESTClient burst") diff --git a/pkg/api/server.go b/pkg/api/server.go index 773b3e31..02f3a442 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -11,6 +11,7 @@ import ( "go.uber.org/zap/zapcore" v1 "github.com/kyverno/policy-reporter/pkg/api/v1" + "github.com/kyverno/policy-reporter/pkg/email/violations" "github.com/kyverno/policy-reporter/pkg/target" ) @@ -25,7 +26,7 @@ type Server interface { // RegisterMetricsHandler adds the optional metrics endpoint RegisterMetricsHandler() // RegisterV1Handler adds the optional v1 REST APIs - RegisterV1Handler(v1.PolicyReportFinder) + RegisterV1Handler(v1.PolicyReportFinder, *violations.Reporter) // RegisterProfilingHandler adds the optional pprof profiling APIs RegisterProfilingHandler() } @@ -54,7 +55,7 @@ func (s *httpServer) RegisterLifecycleHandler() { s.mux.HandleFunc("/ready", ReadyHandler(s.synced)) } -func (s *httpServer) RegisterV1Handler(finder v1.PolicyReportFinder) { +func (s *httpServer) RegisterV1Handler(finder v1.PolicyReportFinder, reporter *violations.Reporter) { handler := v1.NewHandler(finder) s.mux.HandleFunc("/v1/targets", s.middleware(handler.TargetsHandler(s.targets))) @@ -83,6 +84,10 @@ func (s *httpServer) RegisterV1Handler(finder v1.PolicyReportFinder) { s.mux.HandleFunc("/v1/cluster-resources/status-counts", s.middleware(handler.ClusterResourcesStatusCountHandler())) s.mux.HandleFunc("/v1/cluster-resources/results", s.middleware(handler.ClusterResourcesResultHandler())) s.mux.HandleFunc("/v1/cluster-resources/categories", s.middleware(handler.ClusterCategoryListHandler())) + + htmlHandler := v1.NewHTMLHandler(finder, reporter) + + s.mux.HandleFunc("/v1/html-report/violations", s.middleware(htmlHandler.HTMLReport())) } func (s *httpServer) RegisterMetricsHandler() { diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go index 2d677b9f..f44fc15c 100644 --- a/pkg/api/server_test.go +++ b/pkg/api/server_test.go @@ -33,7 +33,7 @@ func Test_NewServer(t *testing.T) { ) server.RegisterMetricsHandler() - server.RegisterV1Handler(nil) + server.RegisterV1Handler(nil, nil) server.RegisterProfilingHandler() serviceRunning := make(chan struct{}) @@ -81,6 +81,6 @@ func Test_SetupServerWithAuth(t *testing.T) { ) server.RegisterMetricsHandler() - server.RegisterV1Handler(nil) + server.RegisterV1Handler(nil, nil) server.RegisterProfilingHandler() } diff --git a/pkg/api/v1/handler_test.go b/pkg/api/v1/handler_test.go index 3d0aa0e5..ae286c4f 100644 --- a/pkg/api/v1/handler_test.go +++ b/pkg/api/v1/handler_test.go @@ -14,6 +14,7 @@ import ( v1 "github.com/kyverno/policy-reporter/pkg/api/v1" "github.com/kyverno/policy-reporter/pkg/crd/api/policyreport/v1alpha2" "github.com/kyverno/policy-reporter/pkg/database" + "github.com/kyverno/policy-reporter/pkg/email/violations" "github.com/kyverno/policy-reporter/pkg/target" "github.com/kyverno/policy-reporter/pkg/target/loki" ) @@ -143,6 +144,7 @@ func Test_V1_API(t *testing.T) { store.Add(ctx, creport) handl := v1.NewHandler(store) + htmlHandl := v1.NewHTMLHandler(store, violations.NewReporter("../../../templates", "Cluster", "Report")) t.Run("ClusterPolicyListHandler", func(t *testing.T) { req, err := http.NewRequest("GET", "/v1/cluster-policies", nil) @@ -593,6 +595,21 @@ func Test_V1_API(t *testing.T) { t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) } }) + + t.Run("HTMLReport", func(t *testing.T) { + req, err := http.NewRequest("GET", "/v1/html-report/violations", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + handler := htmlHandl.HTMLReport() + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) + } + }) } func Test_TargetsAPI(t *testing.T) { diff --git a/pkg/api/v1/html_report.go b/pkg/api/v1/html_report.go new file mode 100644 index 00000000..550fefee --- /dev/null +++ b/pkg/api/v1/html_report.go @@ -0,0 +1,146 @@ +package v1 + +import ( + "net/http" + "slices" + + "github.com/kyverno/policy-reporter/pkg/email/violations" + "go.uber.org/zap" +) + +type HTMLHandler struct { + reporter *violations.Reporter + finder PolicyReportFinder +} + +func (h *HTMLHandler) HTMLReport() http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + sources := make([]violations.Source, 0) + + namespaced, err := h.finder.FetchNamespacedSources(req.Context()) + if err != nil { + zap.L().Error("failed to load data", zap.Error(err)) + w.WriteHeader(http.StatusInternalServerError) + return + } + + cluster, err := h.finder.FetchClusterSources(req.Context()) + if err != nil { + zap.L().Error("failed to load data", zap.Error(err)) + w.WriteHeader(http.StatusInternalServerError) + return + } + + list := append(namespaced, cluster...) + slices.Sort(list) + list = slices.Compact(list) + + for _, source := range list { + cPass, err := h.finder.CountClusterResults(req.Context(), Filter{ + Sources: []string{source}, + Status: []string{"pass"}, + }) + if err != nil { + continue + } + + statusCounts, err := h.finder.FetchNamespacedStatusCounts(req.Context(), Filter{ + Sources: []string{source}, + Status: []string{"pass"}, + }) + if err != nil { + continue + } + + nsPass := make(map[string]int, len(statusCounts)) + for _, s := range statusCounts[0].Items { + nsPass[s.Namespace] = s.Count + } + + clusterResults, err := h.finder.FetchClusterResults(req.Context(), Filter{ + Sources: []string{source}, + Status: []string{"warn", "fail", "error"}, + }, Pagination{SortBy: defaultOrder}) + if err != nil { + continue + } + + cResults := make(map[string][]violations.Result) + for _, r := range clusterResults { + if _, ok := cResults[r.Status]; !ok { + cResults[r.Status] = make([]violations.Result, 0) + } + + cResults[r.Status] = append(cResults[r.Status], violations.Result{ + Kind: r.Kind, + Name: r.Name, + Policy: r.Policy, + Rule: r.Rule, + Status: r.Status, + }) + } + + namespaces, err := h.finder.FetchNamespaces(req.Context(), Filter{ + Sources: []string{source}, + }) + if err != nil { + continue + } + + nsResults := make(map[string]map[string][]violations.Result) + for _, ns := range namespaces { + results, err := h.finder.FetchNamespacedResults(req.Context(), Filter{ + Sources: []string{source}, + Status: []string{"warn", "fail", "error"}, + Namespaces: []string{ns}, + }, Pagination{SortBy: defaultOrder}) + if err != nil { + continue + } + + mapping := make(map[string][]violations.Result) + mapping["warn"] = make([]violations.Result, 0) + mapping["fail"] = make([]violations.Result, 0) + mapping["error"] = make([]violations.Result, 0) + + for _, r := range results { + mapping[r.Status] = append(mapping[r.Status], violations.Result{ + Kind: r.Kind, + Name: r.Name, + Policy: r.Policy, + Rule: r.Rule, + Status: r.Status, + }) + } + + nsResults[ns] = mapping + } + + sources = append(sources, violations.Source{ + Name: source, + ClusterReports: len(cluster) > 0, + ClusterPassed: cPass, + ClusterResults: cResults, + NamespacePassed: nsPass, + NamespaceResults: nsResults, + }) + } + + data, err := h.reporter.Report(sources, "HTML") + if err != nil { + zap.L().Error("failed to load data", zap.Error(err)) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(data.Message)) + } +} + +func NewHTMLHandler(finder PolicyReportFinder, reporter *violations.Reporter) *HTMLHandler { + return &HTMLHandler{ + finder: finder, + reporter: reporter, + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 930dbfbc..593a0180 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -243,18 +243,17 @@ type EmailReport struct { } // EmailReport configuration -type EmailTemplates struct { +type Templates struct { Dir string `mapstructure:"dir"` } // EmailReports configuration type EmailReports struct { - SMTP SMTP `mapstructure:"smtp"` - Templates EmailTemplates `mapstructure:"templates"` - Summary EmailReport `mapstructure:"summary"` - Violations EmailReport `mapstructure:"violations"` - ClusterName string `mapstructure:"clusterName"` - TitlePrefix string `mapstructure:"titlePrefix"` + SMTP SMTP `mapstructure:"smtp"` + Summary EmailReport `mapstructure:"summary"` + Violations EmailReport `mapstructure:"violations"` + ClusterName string `mapstructure:"clusterName"` + TitlePrefix string `mapstructure:"titlePrefix"` } // BasicAuth configuration @@ -387,4 +386,5 @@ type Config struct { Logging Logging `mapstructure:"logging"` Database Database `mapstructure:"database"` SourceConfig map[string]SourceConfig `mapstructure:"sourceConfig"` + Templates Templates `mapstructure:"templates"` } diff --git a/pkg/config/load.go b/pkg/config/load.go index 2c207a3c..c13a374f 100644 --- a/pkg/config/load.go +++ b/pkg/config/load.go @@ -74,7 +74,7 @@ func Load(cmd *cobra.Command) (*Config, error) { } if flag := cmd.Flags().Lookup("template-dir"); flag != nil { - v.BindPFlag("emailReports.templates.dir", flag) + v.BindPFlag("templates.dir", flag) } if flag := cmd.Flags().Lookup("lease-name"); flag != nil { diff --git a/pkg/config/load_test.go b/pkg/config/load_test.go index 0858f359..5c91f4b4 100644 --- a/pkg/config/load_test.go +++ b/pkg/config/load_test.go @@ -58,8 +58,8 @@ func Test_Load(t *testing.T) { if c.Profiling.Enabled != true { t.Errorf("Unexpected Profiling Config: %v", c.Profiling.Enabled) } - if c.EmailReports.Templates.Dir != "/app/templates" { - t.Errorf("Unexpected TemplateDir Config: %s", c.EmailReports.Templates.Dir) + if c.Templates.Dir != "/app/templates" { + t.Errorf("Unexpected TemplateDir Config: %s", c.Templates.Dir) } if c.DBFile != "sqlite-database.db" { t.Errorf("Unexpected DBFile Config: %s", c.DBFile) diff --git a/pkg/config/resolver.go b/pkg/config/resolver.go index 38898769..be376c15 100644 --- a/pkg/config/resolver.go +++ b/pkg/config/resolver.go @@ -28,6 +28,7 @@ import ( "github.com/kyverno/policy-reporter/pkg/email" "github.com/kyverno/policy-reporter/pkg/email/summary" "github.com/kyverno/policy-reporter/pkg/email/violations" + "github.com/kyverno/policy-reporter/pkg/helper" "github.com/kyverno/policy-reporter/pkg/kubernetes" "github.com/kyverno/policy-reporter/pkg/kubernetes/secrets" "github.com/kyverno/policy-reporter/pkg/leaderelection" @@ -384,9 +385,9 @@ func (r *Resolver) SummaryGenerator() (*summary.Generator, error) { func (r *Resolver) SummaryReporter() *summary.Reporter { return summary.NewReporter( - r.config.EmailReports.Templates.Dir, + r.config.Templates.Dir, r.config.EmailReports.ClusterName, - r.config.EmailReports.TitlePrefix, + helper.Defaults(r.config.EmailReports.TitlePrefix, "Report"), ) } @@ -405,7 +406,7 @@ func (r *Resolver) ViolationsGenerator() (*violations.Generator, error) { func (r *Resolver) ViolationsReporter() *violations.Reporter { return violations.NewReporter( - r.config.EmailReports.Templates.Dir, + r.config.Templates.Dir, r.config.EmailReports.ClusterName, r.config.EmailReports.TitlePrefix, ) diff --git a/pkg/config/resolver_test.go b/pkg/config/resolver_test.go index 5de24e9a..0a9be6ee 100644 --- a/pkg/config/resolver_test.go +++ b/pkg/config/resolver_test.go @@ -164,10 +164,10 @@ var testConfig = &config.Config{ Prefix: "prefix", Channels: []*config.GCS{{}}, }, + Templates: config.Templates{ + Dir: "../../templates", + }, EmailReports: config.EmailReports{ - Templates: config.EmailTemplates{ - Dir: "../../templates", - }, SMTP: config.SMTP{ Host: "localhost", Port: 465, diff --git a/templates/violations.html b/templates/violations.html index ea742391..84009a01 100644 --- a/templates/violations.html +++ b/templates/violations.html @@ -56,7 +56,7 @@ -

{{ $source.Name }}

+

{{ title $source.Name }}