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

Email Reports (#164)

* add E-Mail reports

Signed-off-by: Frank Jogeleit <frank.jogeleit@web.de>
This commit is contained in:
Frank Jogeleit 2022-07-03 23:49:16 +02:00 committed by GitHub
parent 7d0440fbca
commit 3e443f126a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 3892 additions and 143 deletions

View file

@ -1,7 +1,16 @@
# Changelog # Changelog
# 2.10.0
* Policy Reporter
* Email Reports
* Send Summary Reports over SMTP to different E-Mails
* Supports channels and filters to send different subsets of Namespaces or Sources to dedicated E-Mails
* Reports are generated and send over dedicated CronJobs, this makes it easy to send the reports as often as needed
* Currently a basic summary and a more detailed violation report is available and can be separatly enabled and configured
# 2.9.5 # 2.9.5
* Fix Policy Reporter Version in the Helm Chart values.yaml * Fix Policy Reporter Version in the Helm Chart values.yaml
# 2.9.4 # 2.9.4
* Policy Reporter * Policy Reporter
* Add [AWS Kinesis](https://aws.amazon.com/kinesis) compatible target * Add [AWS Kinesis](https://aws.amazon.com/kinesis) compatible target

View file

@ -24,6 +24,7 @@ WORKDIR /app
USER 1234 USER 1234
COPY --from=builder /app/LICENSE.md . COPY --from=builder /app/LICENSE.md .
COPY --from=builder /app/templates /app/templates
COPY --from=builder /app/build/policyreporter /app/policyreporter COPY --from=builder /app/build/policyreporter /app/policyreporter
# copy the debian's trusted root CA's to the final image # copy the debian's trusted root CA's to the final image
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt

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 It creates Prometheus Metrics and can send rule validation events to different targets like Loki, Elasticsearch, Slack or Discord
type: application type: application
version: 2.9.5 version: 2.10.0
appVersion: 2.6.2 appVersion: 2.7.0
icon: https://github.com/kyverno/kyverno/raw/main/img/logo.png icon: https://github.com/kyverno/kyverno/raw/main/img/logo.png
home: https://kyverno.github.io/policy-reporter home: https://kyverno.github.io/policy-reporter

View file

@ -0,0 +1,34 @@
emailReports:
clusterName: {{ .Values.emailReports.clusterName }}
{{- with .Values.emailReports.smtp }}
smtp:
{{- toYaml . | nindent 4 }}
{{- end }}
summary:
{{- with .Values.emailReports.summary.to }}
to:
{{- toYaml . | nindent 6 }}
{{- end }}
{{- with .Values.emailReports.summary.filter }}
filter:
{{- toYaml . | nindent 6 }}
{{- end }}
{{- with .Values.emailReports.violations.channels }}
channels:
{{- toYaml . | nindent 6 }}
{{- end }}
violations:
{{- with .Values.emailReports.violations.to }}
to:
{{- toYaml . | nindent 6 }}
{{- end }}
{{- with .Values.emailReports.violations.filter }}
filter:
{{- toYaml . | nindent 6 }}
{{- end }}
{{- with .Values.emailReports.violations.channels }}
channels:
{{- toYaml . | nindent 6 }}
{{- end }}

View file

@ -78,8 +78,8 @@ Create UI target host based on configuration
{{- define "kyverno.securityContext" -}} {{- define "kyverno.securityContext" -}}
{{- if semverCompare "<1.19" .Capabilities.KubeVersion.Version }} {{- if semverCompare "<1.19" .Capabilities.KubeVersion.Version }}
{{ toYaml (omit .Values.securityContext "seccompProfile") }} {{- toYaml (omit .Values.securityContext "seccompProfile") }}
{{- else }} {{- else }}
{{ toYaml .Values.securityContext }} {{- toYaml .Values.securityContext }}
{{- end }} {{- end }}
{{- end }} {{- end }}

View file

@ -0,0 +1,15 @@
{{- if or .Values.emailReports.summary.enabled .Values.emailReports.violations.enabled }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "policyreporter.fullname" . }}-config-email-reports
{{- if .Values.annotations }}
annotations:
{{- toYaml .Values.annotations | nindent 4 }}
{{- end }}
labels:
{{- include "policyreporter.labels" . | nindent 4 }}
type: Opaque
data:
config.yaml: {{ tpl (.Files.Get "config-email-reports.yaml") . | b64enc }}
{{- end }}

View file

@ -0,0 +1,94 @@
{{- if .Values.emailReports.summary.enabled }}
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ include "policyreporter.fullname" . }}-summary-report
labels:
{{- include "policyreporter.labels" . | nindent 4 }}
{{- if .Values.annotations }}
annotations:
{{- toYaml .Values.annotations | nindent 4 }}
{{- end }}
spec:
schedule: {{ .Values.emailReports.summary.schedule | quote }}
jobTemplate:
spec:
activeDeadlineSeconds: {{ .Values.emailReports.summary.activeDeadlineSeconds }}
backoffLimit: {{ .Values.emailReports.summary.backoffLimit }}
{{- if gt (.Values.emailReports.summary.ttlSecondsAfterFinished | toString | atoi) 0 }}
ttlSecondsAfterFinished: {{ .Values.emailReports.summary.ttlSecondsAfterFinished }}
{{- end }}
template:
metadata:
labels:
{{- include "policyreporter.selectorLabels" . | nindent 12 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.global.labels }}
{{- toYaml . | nindent 12 }}
{{- end }}
annotations:
checksum/secret: {{ include (print .Template.BasePath "/config-email-reports-secret.yaml") . | sha256sum | quote }}
{{- with .Values.annotations }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 12 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 12 }}
{{- end }}
serviceAccountName: {{ include "policyreporter.serviceAccountName" . }}
automountServiceAccountToken: true
{{- if .Values.podSecurityContext }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 12 }}
{{- end }}
restartPolicy: {{ .Values.emailReports.summary.restartPolicy }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- if .Values.securityContext }}
securityContext: {{ include "kyverno.securityContext" . | nindent 16 }}
{{- end }}
command:
- /app/policyreporter
- send
- summary
args:
- --config=/app/config.yaml
- --template-dir=/app/templates
volumeMounts:
- name: sqlite
mountPath: /sqlite
- name: config-file
mountPath: /app/config.yaml
subPath: config.yaml
readOnly: true
env:
- name: NAMESPACE
value: {{ .Release.Namespace }}
volumes:
- name: sqlite
emptyDir: {}
- name: config-file
secret:
secretName: {{ include "policyreporter.fullname" . }}-config-email-reports
optional: true
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,94 @@
{{- if .Values.emailReports.violations.enabled }}
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ include "policyreporter.fullname" . }}-violations-report
labels:
{{- include "policyreporter.labels" . | nindent 4 }}
{{- if .Values.annotations }}
annotations:
{{- toYaml .Values.annotations | nindent 4 }}
{{- end }}
spec:
schedule: {{ .Values.emailReports.violations.schedule | quote }}
jobTemplate:
spec:
activeDeadlineSeconds: {{ .Values.emailReports.violations.activeDeadlineSeconds }}
backoffLimit: {{ .Values.emailReports.violations.backoffLimit }}
{{- if gt (.Values.emailReports.violations.ttlSecondsAfterFinished | toString | atoi) 0 }}
ttlSecondsAfterFinished: {{ .Values.emailReports.violations.ttlSecondsAfterFinished }}
{{- end }}
template:
metadata:
labels:
{{- include "policyreporter.selectorLabels" . | nindent 12 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.global.labels }}
{{- toYaml . | nindent 12 }}
{{- end }}
annotations:
checksum/secret: {{ include (print .Template.BasePath "/config-email-reports-secret.yaml") . | sha256sum | quote }}
{{- with .Values.annotations }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 12 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 12 }}
{{- end }}
serviceAccountName: {{ include "policyreporter.serviceAccountName" . }}
automountServiceAccountToken: true
{{- if .Values.podSecurityContext }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 12 }}
{{- end }}
restartPolicy: {{ .Values.emailReports.violations.restartPolicy }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- if .Values.securityContext }}
securityContext: {{ include "kyverno.securityContext" . | nindent 16 }}
{{- end }}
command:
- /app/policyreporter
- send
- violations
args:
- --config=/app/config.yaml
- --template-dir=/app/templates
volumeMounts:
- name: sqlite
mountPath: /sqlite
- name: config-file
mountPath: /app/config.yaml
subPath: config.yaml
readOnly: true
env:
- name: NAMESPACE
value: {{ .Release.Namespace }}
volumes:
- name: sqlite
emptyDir: {}
- name: config-file
secret:
secretName: {{ include "policyreporter.fullname" . }}-config-email-reports
optional: true
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}

View file

@ -2,7 +2,7 @@ image:
registry: ghcr.io registry: ghcr.io
repository: kyverno/policy-reporter repository: kyverno/policy-reporter
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
tag: 2.6.2 tag: 2.7.0
imagePullSecrets: [] imagePullSecrets: []
@ -154,6 +154,50 @@ global:
# require-ns-labels: error # require-ns-labels: error
policyPriorities: {} policyPriorities: {}
emailReports:
clusterName: "" # (optional) - displayed in the E-Mail Report if configured
smtp:
host: ""
port: 465
username: ""
password: ""
from: "" # Displayed From E-Mail Address
encryption: "" # ssl/tls / starttls
# basic summary report
summary:
enabled: false
schedule: "* 8 * * *" # CronJob schedule defines when the report will be send
activeDeadlineSeconds: 300 # timeout in seconds
backoffLimit: 3 # retry counter
ttlSecondsAfterFinished: 0
restartPolicy: Never # pod restart policy
to: [] # list of receiver e-mail addresses
filter: {} # optional filters
# namespaces:
# include: []
# exclude: []
# sources: ['Kyverno']
channels: [] # (optional) channels can be used to to send only a subset of namespaces / sources to dedicated email addresses
# violation summary report
violations:
enabled: false
schedule: "* 8 * * *" # CronJob schedule defines when the report will be send
activeDeadlineSeconds: 300 # timeout in seconds
backoffLimit: 3 # retry counter
ttlSecondsAfterFinished: 0
restartPolicy: Never # pod restart policy
to: [] # list of receiver e-mail addresses
filter: {} # optional filters
# namespaces:
# include: []
# exclude: []
# sources: ['Kyverno']
channels: [] # (optional) channels can be used to to send only a subset of namespaces / sources to dedicated email addresses
# Reference a configuration which already exists instead of creating one # Reference a configuration which already exists instead of creating one
existingTargetConfig: existingTargetConfig:
enabled: false enabled: false

View file

@ -1,11 +1,7 @@
package cmd package cmd
import ( import (
"log"
"github.com/kyverno/policy-reporter/pkg/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper"
) )
// NewCLI creates a new instance of the root CLI // NewCLI creates a new instance of the root CLI
@ -18,64 +14,7 @@ func NewCLI() *cobra.Command {
} }
rootCmd.AddCommand(newRunCMD()) rootCmd.AddCommand(newRunCMD())
rootCmd.AddCommand(newSendCMD())
return rootCmd return rootCmd
} }
func loadConfig(cmd *cobra.Command) (*config.Config, error) {
v := viper.New()
cfgFile := ""
configFlag := cmd.Flags().Lookup("config")
if configFlag != nil {
cfgFile = configFlag.Value.String()
}
if cfgFile != "" {
v.SetConfigFile(cfgFile)
} else {
v.AddConfigPath(".")
v.SetConfigName("config")
}
v.AutomaticEnv()
if err := v.ReadInConfig(); err != nil {
log.Println("[INFO] No configuration file found")
}
if flag := cmd.Flags().Lookup("kubeconfig"); flag != nil {
v.BindPFlag("kubeconfig", flag)
}
if flag := cmd.Flags().Lookup("port"); flag != nil {
v.BindPFlag("api.port", flag)
}
if flag := cmd.Flags().Lookup("rest-enabled"); flag != nil {
v.BindPFlag("rest.enabled", flag)
}
if flag := cmd.Flags().Lookup("metrics-enabled"); flag != nil {
v.BindPFlag("metrics.enabled", flag)
}
if flag := cmd.Flags().Lookup("profile"); flag != nil {
v.BindPFlag("profiling.enabled", flag)
}
if flag := cmd.Flags().Lookup("dbfile"); flag != nil {
v.BindPFlag("dbfile", flag)
}
c := &config.Config{}
err := v.Unmarshal(c)
if c.DBFile == "" {
c.DBFile = "sqlite-database.db"
}
return c, err
}

View file

@ -4,10 +4,10 @@ import (
"flag" "flag"
"log" "log"
"golang.org/x/sync/errgroup"
"github.com/kyverno/policy-reporter/pkg/config" "github.com/kyverno/policy-reporter/pkg/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/sync/errgroup"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd"
) )
@ -17,7 +17,7 @@ func newRunCMD() *cobra.Command {
Use: "run", Use: "run",
Short: "Run PolicyReporter Watcher & HTTP Metrics Server", Short: "Run PolicyReporter Watcher & HTTP Metrics Server",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := loadConfig(cmd) c, err := config.Load(cmd)
if err != nil { if err != nil {
return err return err
} }

26
cmd/send.go Normal file
View file

@ -0,0 +1,26 @@
package cmd
import (
"flag"
"github.com/kyverno/policy-reporter/cmd/send"
"github.com/spf13/cobra"
)
func newSendCMD() *cobra.Command {
cmd := &cobra.Command{
Use: "send",
Short: "Send different kinds of email reports",
}
// For local usage
cmd.PersistentFlags().StringP("kubeconfig", "k", "", "absolute path to the kubeconfig file")
cmd.PersistentFlags().StringP("config", "c", "", "target configuration file")
cmd.PersistentFlags().StringP("template-dir", "t", "./templates", "template directory for email reports")
cmd.AddCommand(send.NewSummaryCMD())
cmd.AddCommand(send.NewViolationsCMD())
flag.Parse()
return cmd
}

106
cmd/send/summary.go Normal file
View file

@ -0,0 +1,106 @@
package send
import (
"log"
"sync"
"github.com/kyverno/policy-reporter/pkg/config"
"github.com/kyverno/policy-reporter/pkg/email/summary"
"github.com/spf13/cobra"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
func NewSummaryCMD() *cobra.Command {
cmd := &cobra.Command{
Use: "summary",
Short: "Send a summary e-mail to the configured emails",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := config.Load(cmd)
if err != nil {
return err
}
var k8sConfig *rest.Config
if c.Kubeconfig != "" {
k8sConfig, err = clientcmd.BuildConfigFromFlags("", c.Kubeconfig)
} else {
k8sConfig, err = rest.InClusterConfig()
}
if err != nil {
return err
}
resolver := config.NewResolver(c, k8sConfig)
generator, err := resolver.SummaryGenerator()
if err != nil {
return err
}
data, err := generator.GenerateData(cmd.Context())
if err != nil {
log.Printf("[ERROR] failed to generate report data: %s\n", err)
return err
}
reporter := resolver.SummaryReporter()
wg := &sync.WaitGroup{}
wg.Add(1 + len(c.EmailReports.Summary.Channels))
go func() {
defer wg.Done()
if len(c.EmailReports.Summary.To) == 0 {
return
}
report, err := reporter.Report(data, c.EmailReports.Summary.Format)
if err != nil {
log.Printf("[ERROR] failed to create report: %s\n", err)
return
}
err = resolver.EmailClient().Send(report, c.EmailReports.Summary.To)
if err != nil {
log.Printf("[ERROR] failed to send report: %s\n", err)
}
}()
for _, ch := range c.EmailReports.Violations.Channels {
go func(channel config.EmailReport) {
defer wg.Done()
if len(channel.To) == 0 {
return
}
sources := summary.FilterSources(data, config.EmailReportFilterFromConfig(channel.Filter), !channel.DisableClusterReports)
if len(sources) == 0 {
log.Printf("[INFO] skip email - no results to send")
return
}
report, err := reporter.Report(sources, channel.Format)
if err != nil {
log.Printf("[ERROR] failed to create report: %s\n", err)
return
}
err = resolver.EmailClient().Send(report, channel.To)
if err != nil {
log.Printf("[ERROR] failed to send report: %s\n", err)
}
}(ch)
}
wg.Wait()
return nil
},
}
return cmd
}

105
cmd/send/violations.go Normal file
View file

@ -0,0 +1,105 @@
package send
import (
"log"
"sync"
"github.com/kyverno/policy-reporter/pkg/config"
"github.com/kyverno/policy-reporter/pkg/email/violations"
"github.com/spf13/cobra"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
func NewViolationsCMD() *cobra.Command {
cmd := &cobra.Command{
Use: "violations",
Short: "Send a violations e-mail to the configured emails",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := config.Load(cmd)
if err != nil {
return err
}
var k8sConfig *rest.Config
if c.Kubeconfig != "" {
k8sConfig, err = clientcmd.BuildConfigFromFlags("", c.Kubeconfig)
} else {
k8sConfig, err = rest.InClusterConfig()
}
if err != nil {
return err
}
resolver := config.NewResolver(c, k8sConfig)
generator, err := resolver.ViolationsGenerator()
if err != nil {
return err
}
data, err := generator.GenerateData(cmd.Context())
if err != nil {
log.Printf("[ERROR] failed to generate report data: %s\n", err)
return err
}
reporter := resolver.ViolationsReporter()
wg := &sync.WaitGroup{}
wg.Add(1 + len(c.EmailReports.Violations.Channels))
go func() {
defer wg.Done()
if len(c.EmailReports.Violations.To) == 0 {
return
}
report, err := reporter.Report(data, c.EmailReports.Violations.Format)
if err != nil {
log.Printf("[ERROR] failed to create report: %s\n", err)
return
}
err = resolver.EmailClient().Send(report, c.EmailReports.Violations.To)
if err != nil {
log.Printf("[ERROR] failed to send report: %s\n", err)
}
}()
for _, ch := range c.EmailReports.Violations.Channels {
go func(channel config.EmailReport) {
defer wg.Done()
if len(channel.To) == 0 {
return
}
sources := violations.FilterSources(data, config.EmailReportFilterFromConfig(channel.Filter), !channel.DisableClusterReports)
if len(sources) == 0 {
log.Printf("[INFO] skip email - no results to send")
}
report, err := reporter.Report(sources, channel.Format)
if err != nil {
log.Printf("[ERROR] failed to create report: %s\n", err)
return
}
err = resolver.EmailClient().Send(report, channel.To)
if err != nil {
log.Printf("[ERROR] failed to send report: %s\n", err)
}
}(ch)
}
wg.Wait()
return nil
},
}
return cmd
}

2
go.mod
View file

@ -54,6 +54,8 @@ require (
github.com/spf13/cast v1.5.0 // indirect github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
github.com/xhit/go-simple-mail/v2 v2.11.0 // indirect
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c // indirect golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c // indirect
golang.org/x/text v0.3.7 // indirect golang.org/x/text v0.3.7 // indirect
google.golang.org/appengine v1.6.7 // indirect google.golang.org/appengine v1.6.7 // indirect

4
go.sum
View file

@ -504,9 +504,13 @@ github.com/subosito/gotenv v1.4.0 h1:yAzM1+SmVcz5R4tXGsNMu1jUl2aOJXoiWUCEwwnGrvs
github.com/subosito/gotenv v1.4.0/go.mod h1:mZd6rFysKEcUhUHXJk0C/08wAgyDBFuwEYL7vWWGaGo= github.com/subosito/gotenv v1.4.0/go.mod h1:mZd6rFysKEcUhUHXJk0C/08wAgyDBFuwEYL7vWWGaGo=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xhit/go-simple-mail/v2 v2.11.0 h1:o/056V50zfkO3Mm5tVdo9rG3ryg4ZmJ2XW5GMinHfVs=
github.com/xhit/go-simple-mail/v2 v2.11.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=

View file

@ -5,6 +5,11 @@ type ValueFilter struct {
Exclude []string `mapstructure:"exclude"` Exclude []string `mapstructure:"exclude"`
} }
type EmailReportFilter struct {
Namespaces ValueFilter `mapstructure:"namespaces"`
Sources []string `mapstructure:"sources"`
}
type TargetFilter struct { type TargetFilter struct {
Namespaces ValueFilter `mapstructure:"namespaces"` Namespaces ValueFilter `mapstructure:"namespaces"`
Priorities ValueFilter `mapstructure:"priorities"` Priorities ValueFilter `mapstructure:"priorities"`
@ -97,6 +102,7 @@ type Webhook struct {
Channels []Webhook `mapstructure:"channels"` Channels []Webhook `mapstructure:"channels"`
} }
// S3 configuration
type S3 struct { type S3 struct {
Name string `mapstructure:"name"` Name string `mapstructure:"name"`
AccessKeyID string `mapstructure:"accessKeyID"` AccessKeyID string `mapstructure:"accessKeyID"`
@ -112,6 +118,7 @@ type S3 struct {
Channels []S3 `mapstructure:"channels"` Channels []S3 `mapstructure:"channels"`
} }
// Kinesis configuration
type Kinesis struct { type Kinesis struct {
Name string `mapstructure:"name"` Name string `mapstructure:"name"`
AccessKeyID string `mapstructure:"accessKeyID"` AccessKeyID string `mapstructure:"accessKeyID"`
@ -126,6 +133,39 @@ type Kinesis struct {
Channels []Kinesis `mapstructure:"channels"` Channels []Kinesis `mapstructure:"channels"`
} }
// SMTP configuration
type SMTP struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
From string `mapstructure:"from"`
Encryption string `mapstructure:"encryption"`
}
// EmailReport configuration
type EmailReport struct {
To []string `mapstructure:"to"`
Format string `mapstructure:"format"`
Filter EmailReportFilter `mapstructure:"filter"`
Channels []EmailReport `mapstructure:"channels"`
DisableClusterReports bool `mapstructure:"disableClusterReports"`
}
// EmailReport configuration
type EmailTemplates 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"`
}
// API configuration // API configuration
type API struct { type API struct {
Port int `mapstructure:"port"` Port int `mapstructure:"port"`
@ -191,4 +231,5 @@ type Config struct {
ReportFilter ReportFilter `mapstructure:"reportFilter"` ReportFilter ReportFilter `mapstructure:"reportFilter"`
Redis Redis `mapstructure:"redis"` Redis Redis `mapstructure:"redis"`
Profiling Profiling `mapstructure:"profiling"` Profiling Profiling `mapstructure:"profiling"`
EmailReports EmailReports `mapstructure:"emailReports"`
} }

70
pkg/config/load.go Normal file
View file

@ -0,0 +1,70 @@
package config
import (
"log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func Load(cmd *cobra.Command) (*Config, error) {
v := viper.New()
cfgFile := ""
configFlag := cmd.Flags().Lookup("config")
if configFlag != nil {
cfgFile = configFlag.Value.String()
}
if cfgFile != "" {
v.SetConfigFile(cfgFile)
} else {
v.AddConfigPath(".")
v.SetConfigName("config")
}
v.AutomaticEnv()
if err := v.ReadInConfig(); err != nil {
log.Println("[INFO] No configuration file found")
}
if flag := cmd.Flags().Lookup("kubeconfig"); flag != nil {
v.BindPFlag("kubeconfig", flag)
}
if flag := cmd.Flags().Lookup("port"); flag != nil {
v.BindPFlag("api.port", flag)
}
if flag := cmd.Flags().Lookup("rest-enabled"); flag != nil {
v.BindPFlag("rest.enabled", flag)
}
if flag := cmd.Flags().Lookup("metrics-enabled"); flag != nil {
v.BindPFlag("metrics.enabled", flag)
}
if flag := cmd.Flags().Lookup("profile"); flag != nil {
v.BindPFlag("profiling.enabled", flag)
}
if flag := cmd.Flags().Lookup("dbfile"); flag != nil {
v.BindPFlag("dbfile", flag)
}
if flag := cmd.Flags().Lookup("template-dir"); flag != nil {
v.BindPFlag("emailReports.templates.dir", flag)
}
c := &Config{}
err := v.Unmarshal(c)
if c.DBFile == "" {
c.DBFile = "sqlite-database.db"
}
return c, err
}

62
pkg/config/load_test.go Normal file
View file

@ -0,0 +1,62 @@
package config_test
import (
"testing"
"github.com/kyverno/policy-reporter/pkg/config"
"github.com/spf13/cobra"
)
func createCMD() *cobra.Command {
cmd := &cobra.Command{}
cmd.Flags().StringP("kubeconfig", "k", "", "absolute path to the kubeconfig file")
cmd.Flags().StringP("config", "c", "", "target configuration file")
cmd.Flags().IntP("port", "p", 8080, "http port for the optional rest api")
cmd.Flags().StringP("dbfile", "d", "sqlite-database.db", "path to the SQLite DB File")
cmd.Flags().BoolP("metrics-enabled", "m", false, "Enable Policy Reporter's Metrics API")
cmd.Flags().BoolP("rest-enabled", "r", false, "Enable Policy Reporter's REST API")
cmd.Flags().Bool("profile", false, "Enable application profiling with pprof")
cmd.Flags().StringP("template-dir", "t", "./templates", "template directory for email reports")
return cmd
}
func Test_Load(t *testing.T) {
cmd := createCMD()
_ = cmd.Flags().Set("kubeconfig", "./config")
_ = cmd.Flags().Set("port", "8081")
_ = cmd.Flags().Set("rest-enabled", "1")
_ = cmd.Flags().Set("metrics-enabled", "1")
_ = cmd.Flags().Set("profile", "1")
_ = cmd.Flags().Set("template-dir", "/app/templates")
_ = cmd.Flags().Set("dbfile", "")
c, err := config.Load(cmd)
if err != nil {
t.Errorf("Unexpected Error: %s", err)
}
if c.Kubeconfig != "./config" {
t.Errorf("Unexpected TemplateDir Config: %s", c.Kubeconfig)
}
if c.API.Port != 8081 {
t.Errorf("Unexpected Port Config: %d", c.API.Port)
}
if c.REST.Enabled != true {
t.Errorf("Unexpected REST Config: %v", c.REST.Enabled)
}
if c.Metrics.Enabled != true {
t.Errorf("Unexpected Metrics Config: %v", c.Metrics.Enabled)
}
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.DBFile != "sqlite-database.db" {
t.Errorf("Unexpected DBFile Config: %s", c.DBFile)
}
}

View file

@ -9,6 +9,10 @@ import (
"github.com/kyverno/policy-reporter/pkg/api" "github.com/kyverno/policy-reporter/pkg/api"
"github.com/kyverno/policy-reporter/pkg/cache" "github.com/kyverno/policy-reporter/pkg/cache"
"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/filter"
"github.com/kyverno/policy-reporter/pkg/helper" "github.com/kyverno/policy-reporter/pkg/helper"
"github.com/kyverno/policy-reporter/pkg/kubernetes" "github.com/kyverno/policy-reporter/pkg/kubernetes"
"github.com/kyverno/policy-reporter/pkg/listener" "github.com/kyverno/policy-reporter/pkg/listener"
@ -26,9 +30,11 @@ import (
"github.com/kyverno/policy-reporter/pkg/target/teams" "github.com/kyverno/policy-reporter/pkg/target/teams"
"github.com/kyverno/policy-reporter/pkg/target/ui" "github.com/kyverno/policy-reporter/pkg/target/ui"
"github.com/kyverno/policy-reporter/pkg/target/webhook" "github.com/kyverno/policy-reporter/pkg/target/webhook"
mail "github.com/xhit/go-simple-mail/v2"
goredis "github.com/go-redis/redis/v8" goredis "github.com/go-redis/redis/v8"
"github.com/kyverno/kyverno/pkg/client/clientset/versioned" "github.com/kyverno/kyverno/pkg/client/clientset/versioned"
wgpolicyk8sv1alpha2 "github.com/kyverno/kyverno/pkg/client/clientset/versioned/typed/policyreport/v1alpha2"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
) )
@ -376,6 +382,72 @@ func (r *Resolver) SkipExistingOnStartup() bool {
return true return true
} }
func (r *Resolver) CRDClient() (wgpolicyk8sv1alpha2.Wgpolicyk8sV1alpha2Interface, error) {
client, err := versioned.NewForConfig(r.k8sConfig)
if err != nil {
return nil, err
}
return client.Wgpolicyk8sV1alpha2(), nil
}
func (r *Resolver) SummaryGenerator() (*summary.Generator, error) {
client, err := r.CRDClient()
if err != nil {
return nil, err
}
return summary.NewGenerator(
client,
EmailReportFilterFromConfig(r.config.EmailReports.Summary.Filter),
!r.config.EmailReports.Summary.DisableClusterReports,
), nil
}
func (r *Resolver) SummaryReporter() *summary.Reporter {
return summary.NewReporter(
r.config.EmailReports.Templates.Dir,
r.config.EmailReports.ClusterName,
)
}
func (r *Resolver) ViolationsGenerator() (*violations.Generator, error) {
client, err := r.CRDClient()
if err != nil {
return nil, err
}
return violations.NewGenerator(
client,
EmailReportFilterFromConfig(r.config.EmailReports.Violations.Filter),
!r.config.EmailReports.Violations.DisableClusterReports,
), nil
}
func (r *Resolver) ViolationsReporter() *violations.Reporter {
return violations.NewReporter(
r.config.EmailReports.Templates.Dir,
r.config.EmailReports.ClusterName,
)
}
func (r *Resolver) SMTPServer() *mail.SMTPServer {
server := mail.NewSMTPClient()
server.Host = r.config.EmailReports.SMTP.Host
server.Port = r.config.EmailReports.SMTP.Port
server.Username = r.config.EmailReports.SMTP.Username
server.Password = r.config.EmailReports.SMTP.Password
server.ConnectTimeout = 10 * time.Second
server.SendTimeout = 10 * time.Second
server.Encryption = email.EncryptionFromString(r.config.EmailReports.SMTP.Encryption)
return server
}
func (r *Resolver) EmailClient() *email.Client {
return email.NewClient(r.config.EmailReports.SMTP.From, r.SMTPServer())
}
func (r *Resolver) PolicyReportClient() (report.PolicyReportClient, error) { func (r *Resolver) PolicyReportClient() (report.PolicyReportClient, error) {
if r.policyReportClient != nil { if r.policyReportClient != nil {
return r.policyReportClient, nil return r.policyReportClient, nil
@ -736,21 +808,31 @@ func createKinesisClient(config Kinesis, parent Kinesis) target.Client {
) )
} }
func createTargetFilter(filter TargetFilter, minimumPriority string, sources []string) *target.Filter { func createTargetFilter(fil TargetFilter, minimumPriority string, sources []string) *target.Filter {
return &target.Filter{ return &target.Filter{
MinimumPriority: minimumPriority, MinimumPriority: minimumPriority,
Sources: sources, Sources: sources,
Namespace: target.Rules{ Namespace: filter.Rules{
Include: filter.Namespaces.Include, Include: fil.Namespaces.Include,
Exclude: filter.Namespaces.Exclude, Exclude: fil.Namespaces.Exclude,
}, },
Priority: target.Rules{ Priority: filter.Rules{
Include: filter.Priorities.Include, Include: fil.Priorities.Include,
Exclude: filter.Priorities.Exclude, Exclude: fil.Priorities.Exclude,
}, },
Policy: target.Rules{ Policy: filter.Rules{
Include: filter.Policies.Include, Include: fil.Policies.Include,
Exclude: filter.Policies.Exclude, Exclude: fil.Policies.Exclude,
}, },
} }
} }
func EmailReportFilterFromConfig(config EmailReportFilter) filter.Filter {
return filter.New(
filter.Rules{
Include: config.Namespaces.Include,
Exclude: config.Namespaces.Exclude,
},
config.Sources,
)
}

View file

@ -94,6 +94,19 @@ var testConfig = &config.Config{
Region: "ru-central1", Region: "ru-central1",
Channels: []config.Kinesis{{}}, Channels: []config.Kinesis{{}},
}, },
EmailReports: config.EmailReports{
Templates: config.EmailTemplates{
Dir: "../../templates",
},
SMTP: config.SMTP{
Host: "localhost",
Port: 465,
Username: "policy-reporter@kyverno.io",
Password: "password",
From: "policy-reporter@kyverno.io",
Encryption: "ssl/tls",
},
},
} }
func Test_ResolveTarget(t *testing.T) { func Test_ResolveTarget(t *testing.T) {
@ -487,6 +500,27 @@ func Test_ResolveClientWithInvalidK8sConfig(t *testing.T) {
} }
} }
func Test_ResolveCRDClient(t *testing.T) {
resolver := config.NewResolver(testConfig, &rest.Config{})
_, err := resolver.CRDClient()
if err != nil {
t.Error("unexpected error")
}
}
func Test_ResolveCRDClientWithInvalidK8sConfig(t *testing.T) {
k8sConfig := &rest.Config{}
k8sConfig.Host = "invalid/url"
resolver := config.NewResolver(testConfig, k8sConfig)
_, err := resolver.CRDClient()
if err == nil {
t.Error("Error: 'host must be a URL or a host:port pair' was expected")
}
}
func Test_RegisterStoreListener(t *testing.T) { func Test_RegisterStoreListener(t *testing.T) {
t.Run("Register StoreListener", func(t *testing.T) { t.Run("Register StoreListener", func(t *testing.T) {
resolver := config.NewResolver(testConfig, &rest.Config{}) resolver := config.NewResolver(testConfig, &rest.Config{})
@ -528,3 +562,82 @@ func Test_RegisterSendResultListener(t *testing.T) {
} }
}) })
} }
func Test_SummaryReportServices(t *testing.T) {
t.Run("Generator", func(t *testing.T) {
resolver := config.NewResolver(testConfig, &rest.Config{})
generator, err := resolver.SummaryGenerator()
if err != nil {
t.Errorf("Unexpected error: %s", err)
}
if generator == nil {
t.Error("Should return Generator Pointer")
}
})
t.Run("Generator.Error", func(t *testing.T) {
k8sConfig := &rest.Config{}
k8sConfig.Host = "invalid/url"
resolver := config.NewResolver(testConfig, k8sConfig)
_, err := resolver.SummaryGenerator()
if err == nil {
t.Error("Error: 'host must be a URL or a host:port pair' was expected")
}
})
t.Run("Reporter", func(t *testing.T) {
resolver := config.NewResolver(testConfig, &rest.Config{})
reporter := resolver.SummaryReporter()
if reporter == nil {
t.Error("Should return Reporter Pointer")
}
})
}
func Test_ViolationReportServices(t *testing.T) {
t.Run("Generator", func(t *testing.T) {
resolver := config.NewResolver(testConfig, &rest.Config{})
generator, err := resolver.ViolationsGenerator()
if err != nil {
t.Errorf("Unexpected error: %s", err)
}
if generator == nil {
t.Error("Should return Generator Pointer")
}
})
t.Run("Generator.Error", func(t *testing.T) {
k8sConfig := &rest.Config{}
k8sConfig.Host = "invalid/url"
resolver := config.NewResolver(testConfig, k8sConfig)
_, err := resolver.ViolationsGenerator()
if err == nil {
t.Error("Error: 'host must be a URL or a host:port pair' was expected")
}
})
t.Run("Reporter", func(t *testing.T) {
resolver := config.NewResolver(testConfig, &rest.Config{})
reporter := resolver.ViolationsReporter()
if reporter == nil {
t.Error("Should return Reporter Pointer")
}
})
}
func Test_SMTP(t *testing.T) {
t.Run("SMTP", func(t *testing.T) {
resolver := config.NewResolver(testConfig, &rest.Config{})
smtp := resolver.SMTPServer()
if smtp == nil {
t.Error("Should return SMTP Pointer")
}
})
t.Run("EmailClient", func(t *testing.T) {
resolver := config.NewResolver(testConfig, &rest.Config{})
client := resolver.EmailClient()
if client == nil {
t.Error("Should return EmailClient Pointer")
}
})
}

63
pkg/email/client.go Normal file
View file

@ -0,0 +1,63 @@
package email
import (
"fmt"
"strings"
mail "github.com/xhit/go-simple-mail/v2"
)
func EncryptionFromString(enc string) mail.Encryption {
switch strings.ToLower(enc) {
case "ssl/tls":
return mail.EncryptionSSLTLS
case "starttls":
return mail.EncryptionSTARTTLS
default:
return mail.EncryptionNone
}
}
type Client struct {
server *mail.SMTPServer
from string
}
func (c *Client) Send(report Report, to []string) error {
if len(to) > 1 {
c.server.KeepAlive = true
}
client, err := c.server.Connect()
if err != nil {
return err
}
for _, to := range to {
msg := mail.NewMSG().
SetFrom(fmt.Sprintf("Policy Reporter <%s>", c.from)).
AddTo(to).
SetSubject(report.Title)
if strings.ToLower(report.Format) == "html" || report.Format == "" {
msg.SetBody(mail.TextHTML, report.Message)
} else {
msg.SetBody(mail.TextPlain, report.Message)
}
if msg.Error != nil {
return msg.Error
}
err = msg.Send(client)
if err != nil {
return err
}
}
return nil
}
func NewClient(from string, server *mail.SMTPServer) *Client {
return &Client{server: server, from: from}
}

36
pkg/email/client_test.go Normal file
View file

@ -0,0 +1,36 @@
package email_test
import (
"testing"
"github.com/kyverno/policy-reporter/pkg/email"
mail "github.com/xhit/go-simple-mail/v2"
)
func Test_EncryptionFromString(t *testing.T) {
t.Run("EncryptionFromString.SSLTLS", func(t *testing.T) {
encryption := email.EncryptionFromString("ssl/tls")
if encryption != mail.EncryptionSSLTLS {
t.Errorf("Unexpected encryption mapping: %d", encryption)
}
})
t.Run("EncryptionFromString.STARTTLS", func(t *testing.T) {
encryption := email.EncryptionFromString("starttls")
if encryption != mail.EncryptionSTARTTLS {
t.Errorf("Unexpected encryption mapping: %d", encryption)
}
})
t.Run("EncryptionFromString.Default", func(t *testing.T) {
encryption := email.EncryptionFromString("")
if encryption != mail.EncryptionNone {
t.Errorf("Unexpected encryption mapping: %d", encryption)
}
})
}
func Test_NewClient(t *testing.T) {
client := email.NewClient("policy-reporter@kyverno.io", nil)
if client == nil {
t.Errorf("Unexpected client result")
}
}

26
pkg/email/functions.go Normal file
View file

@ -0,0 +1,26 @@
package email
import "github.com/kyverno/kyverno/api/policyreport/v1alpha2"
const (
PassColor = "#198754"
WarnColor = "#fd7e14"
FailColor = "#dc3545"
ErrorColor = "#b02a37"
DefaultColor = "#cccccc"
)
func ColorFromStatus(status string) string {
switch status {
case v1alpha2.StatusPass:
return PassColor
case v1alpha2.StatusWarn:
return WarnColor
case v1alpha2.StatusFail:
return FailColor
case v1alpha2.StatusError:
return ErrorColor
default:
return DefaultColor
}
}

View file

@ -0,0 +1,41 @@
package email_test
import (
"testing"
"github.com/kyverno/kyverno/api/policyreport/v1alpha2"
"github.com/kyverno/policy-reporter/pkg/email"
)
func Test_ColorFromStatus(t *testing.T) {
t.Run("ColorFromStatus.Pass", func(t *testing.T) {
color := email.ColorFromStatus(v1alpha2.StatusPass)
if color != email.PassColor {
t.Errorf("Unexpected pass color: %s", color)
}
})
t.Run("ColorFromStatus.Warn", func(t *testing.T) {
color := email.ColorFromStatus(v1alpha2.StatusWarn)
if color != email.WarnColor {
t.Errorf("Unexpected warn color: %s", color)
}
})
t.Run("ColorFromStatus.Fail", func(t *testing.T) {
color := email.ColorFromStatus(v1alpha2.StatusFail)
if color != email.FailColor {
t.Errorf("Unexpected fail color: %s", color)
}
})
t.Run("ColorFromStatus.Error", func(t *testing.T) {
color := email.ColorFromStatus(v1alpha2.StatusError)
if color != email.ErrorColor {
t.Errorf("Unexpected error color: %s", color)
}
})
t.Run("ColorFromStatus.Default", func(t *testing.T) {
color := email.ColorFromStatus("")
if color != email.DefaultColor {
t.Errorf("Unexpected error color: %s", color)
}
})
}

16
pkg/email/model.go Normal file
View file

@ -0,0 +1,16 @@
package email
import (
"context"
)
type Report struct {
Title string
Message string
Format string
ClusterName string
}
type Reporter interface {
Report(ctx context.Context) (Report, error)
}

View file

@ -0,0 +1,222 @@
package summary_test
import (
"github.com/kyverno/kyverno/api/policyreport/v1alpha2"
"github.com/kyverno/kyverno/pkg/client/clientset/versioned/fake"
v1alpha2client "github.com/kyverno/kyverno/pkg/client/clientset/versioned/typed/policyreport/v1alpha2"
"github.com/kyverno/policy-reporter/pkg/filter"
corev1 "k8s.io/api/core/v1"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
var Filter = filter.New(filter.Rules{}, make([]string, 0, 0))
func NewFakeCilent() (v1alpha2client.Wgpolicyk8sV1alpha2Interface, v1alpha2client.PolicyReportInterface, v1alpha2client.ClusterPolicyReportInterface) {
client := fake.NewSimpleClientset().Wgpolicyk8sV1alpha2()
return client, client.PolicyReports("test"), client.ClusterPolicyReports()
}
var PolicyReportCRD = &v1alpha2.PolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "policy-report",
Namespace: "test",
},
Summary: v1alpha2.PolicyReportSummary{
Pass: 0,
Skip: 0,
Warn: 0,
Fail: 3,
Error: 0,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "required-label",
Rule: "app-label-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Deployment",
Name: "nginx",
Namespace: "test",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
Properties: map[string]string{"version": "1.2.0"},
},
{
Message: "message 2",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "priority-test",
Timestamp: v1.Timestamp{Seconds: 1614093000},
},
{
Message: "message 3",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "required-label",
Rule: "app-label-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Deployment",
Name: "name",
Namespace: "test",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988b3",
},
},
},
},
}
var KyvernoPolicyReportCRD = &v1alpha2.PolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "kyverno-policy-report",
Namespace: "kyverno",
},
Summary: v1alpha2.PolicyReportSummary{
Pass: 1,
Skip: 0,
Warn: 0,
Fail: 0,
Error: 0,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusPass,
Scored: true,
Policy: "required-limit",
Rule: "resource-limit-required",
Timestamp: v1.Timestamp{Seconds: 1614093003},
Source: "Kyverno",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Deployment",
Name: "nginx",
Namespace: "kyverno",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
},
},
}
var EmptyPolicyReportCRD = &v1alpha2.PolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "empty-policy-report",
Namespace: "test",
},
Summary: v1alpha2.PolicyReportSummary{},
}
var ClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{
Fail: 1,
Warn: 2,
Error: 3,
Pass: 4,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "cluster-required-label",
Rule: "ns-label-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Namespace",
Name: "policy-reporter",
Namespace: "test",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
},
},
}
var KyvernoClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "kyverno-cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{
Fail: 0,
Warn: 0,
Error: 0,
Pass: 1,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusPass,
Scored: true,
Policy: "cluster-required-quota",
Rule: "ns-quota-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "Kyverno",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Namespace",
Name: "kyverno",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
},
},
}
var MinClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{
Fail: 1,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "cluster-policy",
Rule: "cluster-role",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
},
},
}
var EmptyClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "empty-cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{},
}

View file

@ -0,0 +1,147 @@
package summary
import (
"context"
"sync"
"github.com/kyverno/kyverno/api/policyreport/v1alpha2"
api "github.com/kyverno/kyverno/pkg/client/clientset/versioned/typed/policyreport/v1alpha2"
"github.com/kyverno/policy-reporter/pkg/filter"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type Generator struct {
client api.Wgpolicyk8sV1alpha2Interface
filter filter.Filter
clusterReports bool
}
func (o *Generator) GenerateData(ctx context.Context) ([]Source, error) {
mx := &sync.Mutex{}
sources := make(map[string]*Source)
wg := &sync.WaitGroup{}
if o.clusterReports {
clusterReports, err := o.client.ClusterPolicyReports().List(ctx, v1.ListOptions{})
if err != nil {
return make([]Source, 0, 0), err
}
wg.Add(len(clusterReports.Items))
for _, rep := range clusterReports.Items {
go func(report v1alpha2.ClusterPolicyReport) {
defer wg.Done()
if len(report.Results) == 0 {
return
}
rs := report.Results[0].Source
if !o.filter.ValidateSource(rs) {
return
}
mx.Lock()
s, ok := sources[rs]
if !ok {
s = NewSource(rs, o.clusterReports)
sources[rs] = s
}
mx.Unlock()
s.AddClusterSummary(report.Summary)
}(rep)
}
}
reports, err := o.client.PolicyReports(v1.NamespaceAll).List(ctx, v1.ListOptions{})
if err != nil {
return make([]Source, 0, 0), err
}
wg.Add(len(reports.Items))
for _, rep := range reports.Items {
go func(report v1alpha2.PolicyReport) {
defer wg.Done()
if len(report.Results) == 0 || !o.filter.ValidateNamespace(report.Namespace) {
return
}
rs := report.Results[0].Source
if !o.filter.ValidateSource(rs) {
return
}
mx.Lock()
s, ok := sources[rs]
if !ok {
s = NewSource(rs, o.clusterReports)
sources[rs] = s
}
mx.Unlock()
s.AddNamespacedSummary(report.Namespace, report.Summary)
}(rep)
}
wg.Wait()
list := make([]Source, 0, len(sources))
for _, s := range sources {
list = append(list, *s)
}
return list, nil
}
func NewGenerator(client api.Wgpolicyk8sV1alpha2Interface, filter filter.Filter, clusterReports bool) *Generator {
return &Generator{client, filter, clusterReports}
}
func FilterSources(sources []Source, filter filter.Filter, clusterReports bool) []Source {
newSources := make([]Source, 0)
mx := sync.Mutex{}
wg := &sync.WaitGroup{}
wg.Add(len(sources))
for _, s := range sources {
go func(source Source) {
defer wg.Done()
if !filter.ValidateSource(source.Name) {
return
}
newSource := NewSource(source.Name, clusterReports)
if clusterReports {
newSource.ClusterScopeSummary = source.ClusterScopeSummary
}
for ns, results := range source.NamespaceScopeSummary {
if !filter.ValidateNamespace(ns) {
continue
}
newSource.NamespaceScopeSummary[ns] = results
}
if !clusterReports && len(newSource.NamespaceScopeSummary) == 0 {
return
}
mx.Lock()
newSources = append(newSources, *newSource)
mx.Unlock()
}(s)
}
wg.Wait()
return newSources
}

View file

@ -0,0 +1,174 @@
package summary_test
import (
"context"
"testing"
"github.com/kyverno/policy-reporter/pkg/email/summary"
"github.com/kyverno/policy-reporter/pkg/filter"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func Test_GenerateDataWithSingleSource(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
generator := summary.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if len(data) != 1 {
t.Fatalf("expected one source got: %d", len(data))
}
source := data[0]
if source.Name != "test" {
t.Fatalf("expected source name 'test', got: %s", source.Name)
}
if source.ClusterScopeSummary.Fail != 1 {
t.Fatalf("unexpected Summary Mapping: %d", source.ClusterScopeSummary.Fail)
}
if source.NamespaceScopeSummary["test"].Fail != 3 {
t.Fatalf("unexpected Summary Mapping: %d", source.NamespaceScopeSummary["test"].Fail)
}
}
func Test_GenerateDataWithMultipleSource(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := summary.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if len(data) != 2 {
t.Fatalf("expected two sources, got: %d", len(data))
}
}
func Test_GenerateDataWithSourceFilter(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := summary.NewGenerator(client, filter.New(filter.Rules{}, []string{"test"}), true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if len(data) != 1 {
t.Fatalf("expected one source, got: %d", len(data))
}
}
func Test_FilterSourcesBySource(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := summary.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
data = summary.FilterSources(data, filter.New(filter.Rules{}, []string{"Kyverno"}), true)
if len(data) != 1 {
t.Fatalf("expected one source left, got: %d", len(data))
}
}
func Test_FilterSourcesByNamespace(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := summary.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
data = summary.FilterSources(data, filter.New(filter.Rules{Exclude: []string{"kyverno"}}, []string{}), true)
source := data[0]
if source.Name != "Kyverno" {
source = data[1]
}
if _, ok := source.NamespaceScopeSummary["kyverno"]; ok {
t.Fatal("expected namespace kyverno to be excluded")
}
}
func Test_RemoveEmptySource(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := summary.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
data = summary.FilterSources(data, filter.New(filter.Rules{Exclude: []string{"kyverno"}}, []string{}), false)
if len(data) != 1 {
t.Fatalf("expected one source left, got: %d", len(data))
}
}

View file

@ -0,0 +1,62 @@
package summary
import (
"sync"
"github.com/kyverno/kyverno/api/policyreport/v1alpha2"
)
type Summary struct {
Skip int
Pass int
Warn int
Fail int
Error int
}
type Source struct {
Name string
ClusterScopeSummary *Summary
NamespaceScopeSummary map[string]*Summary
ClusterReports bool
mx *sync.Mutex
}
func (s *Source) AddClusterSummary(sum v1alpha2.PolicyReportSummary) {
s.ClusterScopeSummary.Skip += sum.Skip
s.ClusterScopeSummary.Pass += sum.Pass
s.ClusterScopeSummary.Warn += sum.Warn
s.ClusterScopeSummary.Fail += sum.Fail
s.ClusterScopeSummary.Error += sum.Error
}
func (s *Source) AddNamespacedSummary(ns string, sum v1alpha2.PolicyReportSummary) {
s.mx.Lock()
if d, ok := s.NamespaceScopeSummary[ns]; ok {
d.Skip += sum.Skip
d.Pass += sum.Pass
d.Warn += sum.Warn
d.Fail += sum.Fail
d.Error += sum.Error
} else {
s.NamespaceScopeSummary[ns] = &Summary{
Skip: sum.Skip,
Pass: sum.Pass,
Fail: sum.Fail,
Warn: sum.Warn,
Error: sum.Error,
}
}
s.mx.Unlock()
}
func NewSource(name string, clusterReports bool) *Source {
return &Source{
Name: name,
ClusterScopeSummary: &Summary{},
NamespaceScopeSummary: map[string]*Summary{},
ClusterReports: clusterReports,
mx: new(sync.Mutex),
}
}

View file

@ -0,0 +1,79 @@
package summary_test
import (
"testing"
"github.com/kyverno/kyverno/api/policyreport/v1alpha2"
"github.com/kyverno/policy-reporter/pkg/email/summary"
)
func Test_Source(t *testing.T) {
source := summary.NewSource("kyverno", true)
t.Run("Source.ClusterReports", func(t *testing.T) {
if !source.ClusterReports {
t.Errorf("Expected Surce.ClusterReports to be true")
}
})
t.Run("Source.AddClusterSummary", func(t *testing.T) {
source.AddClusterSummary(v1alpha2.PolicyReportSummary{
Pass: 1,
Warn: 2,
Fail: 4,
Error: 3,
})
if source.ClusterScopeSummary.Pass != 1 {
t.Errorf("Unexpected Pass Summary: %d", source.ClusterScopeSummary.Pass)
}
if source.ClusterScopeSummary.Warn != 2 {
t.Errorf("Unexpected Warn Summary: %d", source.ClusterScopeSummary.Warn)
}
if source.ClusterScopeSummary.Fail != 4 {
t.Errorf("Unexpected Fail Summary: %d", source.ClusterScopeSummary.Fail)
}
if source.ClusterScopeSummary.Error != 3 {
t.Errorf("Unexpected Errpr Summary: %d", source.ClusterScopeSummary.Error)
}
})
t.Run("Source.AddNamespacedSummary", func(t *testing.T) {
source.AddNamespacedSummary("test", v1alpha2.PolicyReportSummary{
Pass: 5,
Warn: 6,
Fail: 7,
Error: 8,
})
if source.NamespaceScopeSummary["test"].Pass != 5 {
t.Errorf("Unexpected Pass Summary: %d", source.ClusterScopeSummary.Pass)
}
if source.NamespaceScopeSummary["test"].Warn != 6 {
t.Errorf("Unexpected Warn Summary: %d", source.ClusterScopeSummary.Warn)
}
if source.NamespaceScopeSummary["test"].Fail != 7 {
t.Errorf("Unexpected Fail Summary: %d", source.ClusterScopeSummary.Fail)
}
if source.NamespaceScopeSummary["test"].Error != 8 {
t.Errorf("Unexpected Errpr Summary: %d", source.ClusterScopeSummary.Error)
}
source.AddNamespacedSummary("test", v1alpha2.PolicyReportSummary{
Pass: 2,
Warn: 1,
Fail: 0,
Error: 3,
})
if source.NamespaceScopeSummary["test"].Pass != 7 {
t.Errorf("Unexpected Pass Summary: %d", source.ClusterScopeSummary.Pass)
}
if source.NamespaceScopeSummary["test"].Warn != 7 {
t.Errorf("Unexpected Warn Summary: %d", source.ClusterScopeSummary.Warn)
}
if source.NamespaceScopeSummary["test"].Fail != 7 {
t.Errorf("Unexpected Fail Summary: %d", source.ClusterScopeSummary.Fail)
}
if source.NamespaceScopeSummary["test"].Error != 11 {
t.Errorf("Unexpected Errpr Summary: %d", source.ClusterScopeSummary.Error)
}
})
}

View file

@ -0,0 +1,42 @@
package summary
import (
"html/template"
"strings"
"time"
"github.com/kyverno/policy-reporter/pkg/email"
)
type Reporter struct {
templateDir string
clusterName string
}
func (o *Reporter) Report(sources []Source, format string) (email.Report, error) {
b := new(strings.Builder)
templ, err := template.ParseFiles(o.templateDir + "/summary.html")
if err != nil {
return email.Report{}, err
}
err = templ.Execute(b, struct {
Sources []Source
ClusterName string
}{Sources: sources, ClusterName: o.clusterName})
if err != nil {
return email.Report{}, err
}
return email.Report{
ClusterName: o.clusterName,
Title: "Summary Report from " + time.Now().Format("2006-02-01"),
Message: b.String(),
Format: format,
}, nil
}
func NewReporter(templateDir, clusterName string) *Reporter {
return &Reporter{templateDir, clusterName}
}

View file

@ -0,0 +1,54 @@
package summary_test
import (
"context"
"fmt"
"os"
"testing"
"github.com/kyverno/policy-reporter/pkg/email/summary"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func Test_CreateReport(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := summary.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
path, err := os.Getwd()
if err != nil {
t.Error(err)
}
fmt.Println(path)
reporter := summary.NewReporter("../../../templates", "Cluster")
report, err := reporter.Report(data, "html")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if report.Message == "" {
t.Fatal("expected validate report message")
}
if report.ClusterName != "Cluster" {
t.Fatal("expected clustername to be set")
}
if report.Format != "html" {
t.Fatal("expected format to be set")
}
}

View file

@ -0,0 +1,321 @@
package violations_test
import (
"github.com/kyverno/kyverno/api/policyreport/v1alpha2"
"github.com/kyverno/kyverno/pkg/client/clientset/versioned/fake"
v1alpha2client "github.com/kyverno/kyverno/pkg/client/clientset/versioned/typed/policyreport/v1alpha2"
"github.com/kyverno/policy-reporter/pkg/filter"
corev1 "k8s.io/api/core/v1"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
var Filter = filter.New(filter.Rules{}, make([]string, 0, 0))
func NewFakeCilent() (v1alpha2client.Wgpolicyk8sV1alpha2Interface, v1alpha2client.PolicyReportInterface, v1alpha2client.ClusterPolicyReportInterface) {
client := fake.NewSimpleClientset().Wgpolicyk8sV1alpha2()
return client, client.PolicyReports("test"), client.ClusterPolicyReports()
}
var PolicyReportCRD = &v1alpha2.PolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "policy-report",
Namespace: "test",
},
Summary: v1alpha2.PolicyReportSummary{
Pass: 0,
Skip: 0,
Warn: 0,
Fail: 3,
Error: 0,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "required-label",
Rule: "app-label-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Deployment",
Name: "nginx",
Namespace: "test",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
Properties: map[string]string{"version": "1.2.0"},
},
{
Message: "message 2",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "priority-test",
Timestamp: v1.Timestamp{Seconds: 1614093000},
},
{
Message: "message 3",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "required-label",
Rule: "app-label-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Deployment",
Name: "name",
Namespace: "test",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988b3",
},
},
},
},
}
var KyvernoPolicyReportCRD = &v1alpha2.PolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "kyverno-policy-report",
Namespace: "kyverno",
},
Summary: v1alpha2.PolicyReportSummary{
Pass: 1,
Skip: 0,
Warn: 1,
Fail: 0,
Error: 0,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusPass,
Scored: true,
Policy: "required-limit",
Rule: "resource-limit-required",
Timestamp: v1.Timestamp{Seconds: 1614093003},
Source: "Kyverno",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Deployment",
Name: "nginx",
Namespace: "kyverno",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
},
{
Message: "message",
Result: v1alpha2.StatusWarn,
Scored: true,
Policy: "required-limit",
Rule: "resource-limit-required",
Timestamp: v1.Timestamp{Seconds: 1614093003},
Source: "Kyverno",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Deployment",
Name: "nginx2",
Namespace: "kyverno",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d2",
},
},
},
},
}
var PassPolicyReportCRD = &v1alpha2.PolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "pass-policy-report",
Namespace: "test",
},
Summary: v1alpha2.PolicyReportSummary{
Pass: 1,
Skip: 0,
Warn: 0,
Fail: 0,
Error: 0,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusPass,
Scored: true,
Policy: "required-limit",
Rule: "resource-limit-required",
Timestamp: v1.Timestamp{Seconds: 1614093003},
Source: "Kyverno",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Deployment",
Name: "nginx",
Namespace: "test",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
},
},
}
var EmptyPolicyReportCRD = &v1alpha2.PolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "empty-policy-report",
Namespace: "test",
},
Summary: v1alpha2.PolicyReportSummary{},
}
var ClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{
Fail: 1,
Warn: 0,
Error: 0,
Pass: 1,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "cluster-required-label",
Rule: "ns-label-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Namespace",
Name: "policy-reporter",
Namespace: "test",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
},
{
Message: "message",
Result: v1alpha2.StatusPass,
Scored: true,
Policy: "cluster-required-label",
Rule: "ns-label-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Namespace",
Name: "test",
Namespace: "test",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
},
},
}
var KyvernoClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "kyverno-cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{
Fail: 1,
Warn: 0,
Error: 0,
Pass: 0,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "cluster-required-quota",
Rule: "ns-quota-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "Kyverno",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Namespace",
Name: "kyverno",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
},
},
}
var MinClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{
Fail: 1,
Pass: 1,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "cluster-policy",
Rule: "cluster-role",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
},
},
}
var PassClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "pass-cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{
Pass: 1,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusPass,
Scored: true,
Policy: "cluster-policy-pass",
Rule: "cluster-role-pass",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
},
},
}
var EmptyClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "empty-cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{},
}

View file

@ -0,0 +1,184 @@
package violations
import (
"context"
"sync"
"github.com/kyverno/kyverno/api/policyreport/v1alpha2"
api "github.com/kyverno/kyverno/pkg/client/clientset/versioned/typed/policyreport/v1alpha2"
"github.com/kyverno/policy-reporter/pkg/filter"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type Generator struct {
client api.Wgpolicyk8sV1alpha2Interface
filter filter.Filter
clusterReports bool
}
func (o *Generator) GenerateData(ctx context.Context) ([]Source, error) {
mx := &sync.Mutex{}
sources := make(map[string]*Source)
wg := &sync.WaitGroup{}
if o.clusterReports {
clusterReports, err := o.client.ClusterPolicyReports().List(ctx, v1.ListOptions{})
if err != nil {
return make([]Source, 0, 0), err
}
wg.Add(len(clusterReports.Items))
for _, rep := range clusterReports.Items {
go func(report v1alpha2.ClusterPolicyReport) {
defer wg.Done()
if len(report.Results) == 0 {
return
}
rs := report.Results[0].Source
if !o.filter.ValidateSource(rs) {
return
}
mx.Lock()
s, ok := sources[rs]
if !ok {
s = NewSource(rs, o.clusterReports)
sources[rs] = s
}
mx.Unlock()
s.AddClusterPassed(report.Summary.Pass)
length := len(report.Results)
if length == 0 || length == report.Summary.Pass+report.Summary.Skip {
return
}
for _, result := range report.Results {
if result.Result == v1alpha2.StatusPass || result.Result == v1alpha2.StatusSkip {
continue
}
s.AddClusterResults(mapResult(result))
}
}(rep)
}
}
reports, err := o.client.PolicyReports(v1.NamespaceAll).List(ctx, v1.ListOptions{})
if err != nil {
return make([]Source, 0, 0), err
}
wg.Add(len(reports.Items))
for _, rep := range reports.Items {
go func(report v1alpha2.PolicyReport) {
defer wg.Done()
if len(report.Results) == 0 {
return
}
rs := report.Results[0].Source
if !o.filter.ValidateSource(rs) || !o.filter.ValidateNamespace(report.Namespace) {
return
}
mx.Lock()
s, ok := sources[rs]
if !ok {
s = NewSource(rs, o.clusterReports)
sources[rs] = s
}
mx.Unlock()
s.AddNamespacedPassed(report.Namespace, report.Summary.Pass)
length := len(report.Results)
if length == 0 || length == report.Summary.Pass+report.Summary.Skip {
s.InitResults(report.Namespace)
return
}
for _, result := range report.Results {
if result.Result == v1alpha2.StatusPass || result.Result == v1alpha2.StatusSkip {
continue
}
s.AddNamespacedResults(report.Namespace, mapResult(result))
}
}(rep)
}
wg.Wait()
list := make([]Source, 0, len(sources))
for _, s := range sources {
list = append(list, *s)
}
return list, nil
}
func NewGenerator(client api.Wgpolicyk8sV1alpha2Interface, filter filter.Filter, clusterReports bool) *Generator {
return &Generator{client, filter, clusterReports}
}
func FilterSources(sources []Source, filter filter.Filter, clusterReports bool) []Source {
newSources := make([]Source, 0)
mx := sync.Mutex{}
wg := &sync.WaitGroup{}
wg.Add(len(sources))
for _, s := range sources {
go func(source Source) {
defer wg.Done()
if !filter.ValidateSource(source.Name) {
return
}
newSource := NewSource(source.Name, clusterReports)
if clusterReports {
newSource.ClusterPassed = source.ClusterPassed
newSource.ClusterResults = source.ClusterResults
}
for ns, passed := range source.NamespacePassed {
if !filter.ValidateNamespace(ns) {
continue
}
newSource.AddNamespacedPassed(ns, passed)
}
for ns, results := range source.NamespaceResults {
if !filter.ValidateNamespace(ns) {
continue
}
newSource.NamespaceResults[ns] = results
}
if !clusterReports && len(newSource.NamespaceResults) == 0 {
return
}
mx.Lock()
newSources = append(newSources, *newSource)
mx.Unlock()
}(s)
}
wg.Wait()
return newSources
}

View file

@ -0,0 +1,193 @@
package violations_test
import (
"context"
"testing"
"github.com/kyverno/policy-reporter/pkg/email/violations"
"github.com/kyverno/policy-reporter/pkg/filter"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func Test_GenerateDataWithSingleSource(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
generator := violations.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if len(data) != 1 {
t.Fatalf("expected one source got: %d", len(data))
}
source := data[0]
if source.Name != "test" {
t.Fatalf("expected source name 'test', got: %s", source.Name)
}
if source.ClusterPassed != 1 {
t.Fatalf("unexpected Summary Mapping: %d", source.ClusterPassed)
}
if len(source.NamespaceResults["test"]["fail"]) != 3 {
t.Fatalf("unexpected Summary Mapping: %d", len(source.NamespaceResults["test"]["fail"]))
}
result := source.NamespaceResults["test"]["fail"][0]
if result.Kind != "Deployment" {
t.Fatalf("unexpected kind: %s", result.Kind)
}
if result.Name != "nginx" {
t.Fatalf("unexpected name: %s", result.Kind)
}
if result.Policy != "required-label" {
t.Fatalf("unexpected policy: %s", result.Kind)
}
if result.Rule != "app-label-required" {
t.Fatalf("unexpected rule: %s", result.Kind)
}
if result.Status != "fail" {
t.Fatalf("unexpected status: %s", result.Status)
}
}
func Test_GenerateDataWithMultipleSource(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, PassPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, PassClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := violations.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if len(data) != 2 {
t.Fatalf("expected two sources, got: %d", len(data))
}
}
func Test_GenerateDataWithSourceFilter(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := violations.NewGenerator(client, filter.New(filter.Rules{}, []string{"test"}), true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if len(data) != 1 {
t.Fatalf("expected one source, got: %d", len(data))
}
}
func Test_FilterSourcesBySource(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := violations.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
data = violations.FilterSources(data, filter.New(filter.Rules{}, []string{"Kyverno"}), true)
if len(data) != 1 {
t.Fatalf("expected one source left, got: %d", len(data))
}
}
func Test_FilterSourcesByNamespace(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := violations.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
data = violations.FilterSources(data, filter.New(filter.Rules{Exclude: []string{"kyverno"}}, []string{}), true)
source := data[0]
if source.Name != "Kyverno" {
source = data[1]
}
if _, ok := source.NamespaceResults["kyverno"]; ok {
t.Fatal("expected namespace kyverno to be excluded")
}
}
func Test_RemoveEmptySource(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := violations.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
data = violations.FilterSources(data, filter.New(filter.Rules{Exclude: []string{"kyverno"}}, []string{}), false)
if len(data) != 1 {
t.Fatalf("expected one source left, got: %d", len(data))
}
}

View file

@ -0,0 +1,114 @@
package violations
import (
"sync"
"github.com/kyverno/kyverno/api/policyreport/v1alpha2"
)
type Result struct {
Policy string
Rule string
Kind string
Name string
Status string
}
func mapResult(res v1alpha2.PolicyReportResult) []Result {
count := len(res.Resources)
if count == 0 {
return []Result{{
Policy: res.Policy,
Rule: res.Rule,
Status: string(res.Result),
}}
}
list := make([]Result, 0, count)
for _, re := range res.Resources {
list = append(list, Result{
Policy: res.Policy,
Rule: res.Rule,
Name: re.Name,
Kind: re.Kind,
Status: string(res.Result),
})
}
return list
}
type Source struct {
Name string
ClusterPassed int
NamespacePassed map[string]int
ClusterResults map[string][]Result
NamespaceResults map[string]map[string][]Result
ClusterReports bool
passMX *sync.Mutex
crMX *sync.Mutex
nrMX *sync.Mutex
}
func (s *Source) AddClusterResults(result []Result) {
s.crMX.Lock()
s.ClusterResults[result[0].Status] = append(s.ClusterResults[result[0].Status], result...)
s.crMX.Unlock()
}
func (s *Source) AddClusterPassed(results int) {
s.ClusterPassed += results
}
func (s *Source) AddNamespacedPassed(ns string, results int) {
s.passMX.Lock()
s.NamespacePassed[ns] += results
s.passMX.Unlock()
}
func (s *Source) AddNamespacedResults(ns string, result []Result) {
s.nrMX.Lock()
if nr, ok := s.NamespaceResults[ns]; ok {
s.NamespaceResults[ns][result[0].Status] = append(nr[result[0].Status], result...)
} else {
s.NamespaceResults[ns] = map[string][]Result{
v1alpha2.StatusWarn: make([]Result, 0),
v1alpha2.StatusFail: make([]Result, 0),
v1alpha2.StatusError: make([]Result, 0),
}
s.NamespaceResults[ns][result[0].Status] = result
}
s.nrMX.Unlock()
}
func (s Source) InitResults(ns string) {
s.nrMX.Lock()
if _, ok := s.NamespaceResults[ns]; !ok {
s.NamespaceResults[ns] = map[string][]Result{
v1alpha2.StatusWarn: make([]Result, 0),
v1alpha2.StatusFail: make([]Result, 0),
v1alpha2.StatusError: make([]Result, 0),
}
}
s.nrMX.Unlock()
}
func NewSource(name string, clusterReports bool) *Source {
return &Source{
Name: name,
ClusterReports: clusterReports,
ClusterResults: map[string][]Result{
v1alpha2.StatusWarn: make([]Result, 0),
v1alpha2.StatusFail: make([]Result, 0),
v1alpha2.StatusError: make([]Result, 0),
},
NamespaceResults: map[string]map[string][]Result{},
NamespacePassed: map[string]int{},
passMX: new(sync.Mutex),
crMX: new(sync.Mutex),
nrMX: new(sync.Mutex),
}
}

View file

@ -0,0 +1,101 @@
package violations_test
import (
"testing"
"github.com/kyverno/policy-reporter/pkg/email/violations"
)
func Test_Source(t *testing.T) {
source := violations.NewSource("kyverno", true)
t.Run("Source.ClusterReports", func(t *testing.T) {
if !source.ClusterReports {
t.Errorf("Expected Surce.ClusterReports to be true")
}
})
t.Run("Source.AddClusterPassed", func(t *testing.T) {
source.AddClusterPassed(3)
if source.ClusterPassed != 3 {
t.Errorf("Unexpected Summary: %d", source.ClusterPassed)
}
})
t.Run("Source.AddClusterResults", func(t *testing.T) {
source.AddClusterResults([]violations.Result{{
Name: "policy-reporter",
Kind: "Namespace",
Policy: "require-label",
Rule: "require-label",
Status: "fail",
}, {
Name: "develop",
Kind: "Namespace",
Policy: "require-label",
Rule: "require-label",
Status: "fail",
}})
if len(source.ClusterResults["fail"]) != 2 {
t.Errorf("Unexpected amount of failing Cluster Results: %d", len(source.ClusterResults["fail"]))
}
})
t.Run("Source.AddNamespacedPassed", func(t *testing.T) {
source.AddNamespacedPassed("test", 2)
if source.NamespacePassed["test"] != 2 {
t.Errorf("Unexpected amount of passed Results in Namespace: %d", source.NamespacePassed["test"])
}
source.AddNamespacedPassed("test", 3)
if source.NamespacePassed["test"] != 5 {
t.Errorf("Unexpected amount of passed Results in Namespace: %d", source.NamespacePassed["test"])
}
})
t.Run("Source.AddNamespacedResults", func(t *testing.T) {
source.AddNamespacedResults("test", []violations.Result{{
Name: "policy-reporter",
Kind: "Deployment",
Policy: "require-label",
Rule: "require-label",
Status: "fail",
}})
if len(source.NamespaceResults["test"]["fail"]) != 1 {
t.Errorf("Unexpected amount of failing Results in namespace after init: %d", len(source.ClusterResults["fail"]))
}
if source.NamespaceResults["test"]["warn"] == nil {
t.Errorf("Expected warn map is initialized")
}
if source.NamespaceResults["test"]["error"] == nil {
t.Errorf("Expected warn map is initialized")
}
source.AddNamespacedResults("test", []violations.Result{{
Name: "policy-reporter-ui",
Kind: "Deployment",
Policy: "require-label",
Rule: "require-label",
Status: "fail",
}})
if len(source.NamespaceResults["test"]["fail"]) != 2 {
t.Errorf("Unexpected amount of failing Results in namespace after add: %d", len(source.ClusterResults["fail"]))
}
})
t.Run("Source.InitResults", func(t *testing.T) {
source.InitResults("test")
if source.NamespaceResults["test"]["fail"] == nil {
t.Errorf("Expected fail map is initialized")
}
if source.NamespaceResults["test"]["warn"] == nil {
t.Errorf("Expected warn map is initialized")
}
if source.NamespaceResults["test"]["error"] == nil {
t.Errorf("Expected warn map is initialized")
}
})
}

View file

@ -0,0 +1,54 @@
package violations
import (
"html/template"
"strings"
"time"
"github.com/kyverno/policy-reporter/pkg/email"
)
type Reporter struct {
templateDir string
clusterName string
}
func (o *Reporter) Report(sources []Source, format string) (email.Report, error) {
b := new(strings.Builder)
vioTempl := template.New("violations.html").Funcs(template.FuncMap{
"color": email.ColorFromStatus,
"title": strings.Title,
"hasViolations": func(results map[string][]Result) bool {
return (len(results["warn"]) + len(results["fail"]) + len(results["error"])) > 0
},
"lenNamespaceResults": func(source Source, ns, status string) int {
return len(source.NamespaceResults[ns][status])
},
})
templ, err := vioTempl.ParseFiles(o.templateDir + "/violations.html")
if err != nil {
return email.Report{}, err
}
err = templ.Execute(b, struct {
Sources []Source
Status []string
ClusterName string
}{Sources: sources, Status: []string{"warn", "fail", "error"}, ClusterName: o.clusterName})
if err != nil {
return email.Report{}, err
}
return email.Report{
ClusterName: o.clusterName,
Title: "Summary Report from " + time.Now().Format("2006-02-01"),
Message: b.String(),
Format: format,
}, nil
}
func NewReporter(templateDir string, clusterName string) *Reporter {
return &Reporter{templateDir, clusterName}
}

View file

@ -0,0 +1,54 @@
package violations_test
import (
"context"
"fmt"
"os"
"testing"
"github.com/kyverno/policy-reporter/pkg/email/violations"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func Test_CreateReport(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := violations.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
path, err := os.Getwd()
if err != nil {
t.Error(err)
}
fmt.Println(path)
reporter := violations.NewReporter("../../../templates", "Cluster")
report, err := reporter.Report(data, "html")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if report.Message == "" {
t.Fatal("expected validate report message")
}
if report.ClusterName != "Cluster" {
t.Fatal("expected clustername to be set")
}
if report.Format != "html" {
t.Fatal("expected format to be set")
}
}

67
pkg/filter/filter.go Normal file
View file

@ -0,0 +1,67 @@
package filter
import (
"github.com/kyverno/go-wildcard"
"github.com/kyverno/policy-reporter/pkg/helper"
)
type Filter struct {
namespace Rules
sources []string
}
func (f Filter) ValidateSource(source string) bool {
return ValidateSource(source, f.sources)
}
func (f Filter) ValidateNamespace(namespace string) bool {
return ValidateNamespace(namespace, f.namespace)
}
func New(namespaces Rules, sources []string) Filter {
return Filter{namespaces, sources}
}
func ValidateNamespace(namespace string, namespaces Rules) bool {
if namespace != "" && len(namespaces.Include) > 0 {
for _, ns := range namespaces.Include {
if wildcard.Match(ns, namespace) {
return true
}
}
return false
} else if namespace != "" && len(namespaces.Exclude) > 0 {
for _, ns := range namespaces.Exclude {
if wildcard.Match(ns, namespace) {
return false
}
}
}
return true
}
func ValidateRule(value string, rules Rules) bool {
if len(rules.Include) > 0 {
for _, ns := range rules.Include {
if wildcard.Match(ns, value) {
return true
}
}
return false
} else if len(rules.Exclude) > 0 {
for _, ns := range rules.Exclude {
if wildcard.Match(ns, value) {
return false
}
}
}
return true
}
func ValidateSource(source string, sources []string) bool {
return len(sources) == 0 || helper.Contains(source, sources)
}

88
pkg/filter/filter_test.go Normal file
View file

@ -0,0 +1,88 @@
package filter_test
import (
"testing"
"github.com/kyverno/policy-reporter/pkg/filter"
)
func Test_BaseClient(t *testing.T) {
t.Run("Validate Default", func(t *testing.T) {
filter := filter.New(filter.Rules{}, []string{})
if !filter.ValidateNamespace("test") {
t.Errorf("Unexpected Validation Result without configured rules")
}
if !filter.ValidateSource("Kyverno") {
t.Errorf("Unexpected Validation Result without configured rules")
}
})
t.Run("Validate Source", func(t *testing.T) {
filter := filter.New(filter.Rules{}, []string{"jsPolicy"})
if filter.ValidateSource("test") {
t.Errorf("Unexpected Validation Result")
}
if !filter.ValidateSource("jsPolicy") {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Namespace match", func(t *testing.T) {
filter := filter.New(filter.Rules{Exclude: []string{"default"}}, []string{})
if filter.ValidateNamespace("default") {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Namespace mismatch", func(t *testing.T) {
filter := filter.New(filter.Rules{Exclude: []string{"team-a"}}, []string{})
if !filter.ValidateNamespace("default") {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Include Namespace match", func(t *testing.T) {
filter := filter.New(filter.Rules{Include: []string{"default"}}, []string{})
if !filter.ValidateNamespace("default") {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Namespace mismatch", func(t *testing.T) {
filter := filter.New(filter.Rules{Include: []string{"team-a"}}, []string{})
if filter.ValidateNamespace("default") {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Rule match", func(t *testing.T) {
result := filter.ValidateRule("test", filter.Rules{Exclude: []string{"team-a"}})
if !result {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Rule mismatch", func(t *testing.T) {
result := filter.ValidateRule("test", filter.Rules{Exclude: []string{"test"}})
if result {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Include Rule match", func(t *testing.T) {
result := filter.ValidateRule("test", filter.Rules{Include: []string{"test"}})
if !result {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Include Rule mismatch", func(t *testing.T) {
result := filter.ValidateRule("test", filter.Rules{Include: []string{"team-a"}})
if result {
t.Errorf("Unexpected Validation Result")
}
})
}

6
pkg/filter/model.go Normal file
View file

@ -0,0 +1,6 @@
package filter
type Rules struct {
Exclude []string
Include []string
}

13
pkg/helper/utils.go Normal file
View file

@ -0,0 +1,13 @@
package helper
import "strings"
func Contains(source string, sources []string) bool {
for _, s := range sources {
if strings.EqualFold(s, source) {
return true
}
}
return false
}

View file

@ -1,10 +1,9 @@
package target package target
import ( import (
"strings" "github.com/kyverno/policy-reporter/pkg/filter"
"github.com/kyverno/policy-reporter/pkg/helper"
"github.com/kyverno/policy-reporter/pkg/report" "github.com/kyverno/policy-reporter/pkg/report"
"github.com/minio/pkg/wildcard"
) )
// Client for a provided Target // Client for a provided Target
@ -29,15 +28,15 @@ type Rules struct {
} }
type Filter struct { type Filter struct {
Namespace Rules Namespace filter.Rules
Priority Rules Priority filter.Rules
Policy Rules Policy filter.Rules
MinimumPriority string MinimumPriority string
Sources []string Sources []string
} }
func (f *Filter) Validate(result report.Result) bool { func (f *Filter) Validate(result report.Result) bool {
if len(f.Sources) > 0 && !contains(result.Source, f.Sources) { if len(f.Sources) > 0 && !helper.Contains(result.Source, f.Sources) {
return false return false
} }
@ -61,49 +60,21 @@ func (f *Filter) Validate(result report.Result) bool {
} }
func (f *Filter) validateNamespaceRules(result report.Result) bool { func (f *Filter) validateNamespaceRules(result report.Result) bool {
if result.HasResource() && len(f.Namespace.Include) > 0 { if !result.HasResource() {
for _, ns := range f.Namespace.Include { return true
if wildcard.Match(ns, result.Resource.Namespace) {
return true
}
}
return false
} else if result.HasResource() && len(f.Namespace.Exclude) > 0 {
for _, ns := range f.Namespace.Exclude {
if wildcard.Match(ns, result.Resource.Namespace) {
return false
}
}
} }
return true return filter.ValidateNamespace(result.Resource.Namespace, f.Namespace)
} }
func (f *Filter) validatePolicyRules(result report.Result) bool { func (f *Filter) validatePolicyRules(result report.Result) bool {
if len(f.Policy.Include) > 0 { return filter.ValidateRule(result.Policy, f.Policy)
for _, ns := range f.Policy.Include {
if wildcard.Match(ns, result.Policy) {
return true
}
}
return false
} else if len(f.Policy.Exclude) > 0 {
for _, ns := range f.Policy.Exclude {
if wildcard.Match(ns, result.Policy) {
return false
}
}
}
return true
} }
func (f *Filter) validatePriorityRules(result report.Result) bool { func (f *Filter) validatePriorityRules(result report.Result) bool {
if len(f.Priority.Include) > 0 { if len(f.Priority.Include) > 0 {
return contains(result.Priority.String(), f.Priority.Include) return helper.Contains(result.Priority.String(), f.Priority.Include)
} else if len(f.Priority.Exclude) > 0 && contains(result.Priority.String(), f.Priority.Exclude) { } else if len(f.Priority.Exclude) > 0 && helper.Contains(result.Priority.String(), f.Priority.Exclude) {
return false return false
} }
@ -139,13 +110,3 @@ func (c *BaseClient) SkipExistingOnStartup() bool {
func NewBaseClient(name string, skipExistingOnStartup bool, filter *Filter) BaseClient { func NewBaseClient(name string, skipExistingOnStartup bool, filter *Filter) BaseClient {
return BaseClient{name, skipExistingOnStartup, filter} return BaseClient{name, skipExistingOnStartup, filter}
} }
func contains(source string, sources []string) bool {
for _, s := range sources {
if strings.EqualFold(s, source) {
return true
}
}
return false
}

View file

@ -3,6 +3,7 @@ package target_test
import ( import (
"testing" "testing"
"github.com/kyverno/policy-reporter/pkg/filter"
"github.com/kyverno/policy-reporter/pkg/report" "github.com/kyverno/policy-reporter/pkg/report"
"github.com/kyverno/policy-reporter/pkg/target" "github.com/kyverno/policy-reporter/pkg/target"
) )
@ -26,6 +27,18 @@ var result = report.Result{
}, },
} }
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.WarningPriority,
Status: report.Fail,
Severity: report.High,
Category: "resources",
Scored: true,
Source: "Kyverno",
}
func Test_BaseClient(t *testing.T) { func Test_BaseClient(t *testing.T) {
t.Run("Validate Default", func(t *testing.T) { t.Run("Validate Default", func(t *testing.T) {
filter := &target.Filter{} filter := &target.Filter{}
@ -49,29 +62,37 @@ func Test_BaseClient(t *testing.T) {
} }
}) })
t.Run("Validate ClusterResult", func(t *testing.T) {
filter := &target.Filter{Namespace: filter.Rules{Include: []string{"default"}}}
if !filter.Validate(result2) {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Namespace match", func(t *testing.T) { t.Run("Validate Exclude Namespace match", func(t *testing.T) {
filter := &target.Filter{Namespace: target.Rules{Exclude: []string{"default"}}} filter := &target.Filter{Namespace: filter.Rules{Exclude: []string{"default"}}}
if filter.Validate(result) { if filter.Validate(result) {
t.Errorf("Unexpected Validation Result") t.Errorf("Unexpected Validation Result")
} }
}) })
t.Run("Validate Exclude Namespace mismatch", func(t *testing.T) { t.Run("Validate Exclude Namespace mismatch", func(t *testing.T) {
filter := &target.Filter{Namespace: target.Rules{Exclude: []string{"team-a"}}} filter := &target.Filter{Namespace: filter.Rules{Exclude: []string{"team-a"}}}
if !filter.Validate(result) { if !filter.Validate(result) {
t.Errorf("Unexpected Validation Result") t.Errorf("Unexpected Validation Result")
} }
}) })
t.Run("Validate Include Namespace match", func(t *testing.T) { t.Run("Validate Include Namespace match", func(t *testing.T) {
filter := &target.Filter{Namespace: target.Rules{Include: []string{"default"}}} filter := &target.Filter{Namespace: filter.Rules{Include: []string{"default"}}}
if !filter.Validate(result) { if !filter.Validate(result) {
t.Errorf("Unexpected Validation Result") t.Errorf("Unexpected Validation Result")
} }
}) })
t.Run("Validate Exclude Namespace mismatch", func(t *testing.T) { t.Run("Validate Exclude Namespace mismatch", func(t *testing.T) {
filter := &target.Filter{Namespace: target.Rules{Include: []string{"team-a"}}} filter := &target.Filter{Namespace: filter.Rules{Include: []string{"team-a"}}}
if filter.Validate(result) { if filter.Validate(result) {
t.Errorf("Unexpected Validation Result") t.Errorf("Unexpected Validation Result")
@ -79,28 +100,28 @@ func Test_BaseClient(t *testing.T) {
}) })
t.Run("Validate Exclude Priority match", func(t *testing.T) { t.Run("Validate Exclude Priority match", func(t *testing.T) {
filter := &target.Filter{Priority: target.Rules{Exclude: []string{report.WarningPriority.String()}}} filter := &target.Filter{Priority: filter.Rules{Exclude: []string{report.WarningPriority.String()}}}
if filter.Validate(result) { if filter.Validate(result) {
t.Errorf("Unexpected Validation Result") t.Errorf("Unexpected Validation Result")
} }
}) })
t.Run("Validate Exclude Priority mismatch", func(t *testing.T) { t.Run("Validate Exclude Priority mismatch", func(t *testing.T) {
filter := &target.Filter{Priority: target.Rules{Exclude: []string{report.ErrorPriority.String()}}} filter := &target.Filter{Priority: filter.Rules{Exclude: []string{report.ErrorPriority.String()}}}
if !filter.Validate(result) { if !filter.Validate(result) {
t.Errorf("Unexpected Validation Result") t.Errorf("Unexpected Validation Result")
} }
}) })
t.Run("Validate Include Priority match", func(t *testing.T) { t.Run("Validate Include Priority match", func(t *testing.T) {
filter := &target.Filter{Priority: target.Rules{Include: []string{report.WarningPriority.String()}}} filter := &target.Filter{Priority: filter.Rules{Include: []string{report.WarningPriority.String()}}}
if !filter.Validate(result) { if !filter.Validate(result) {
t.Errorf("Unexpected Validation Result") t.Errorf("Unexpected Validation Result")
} }
}) })
t.Run("Validate Exclude Priority mismatch", func(t *testing.T) { t.Run("Validate Exclude Priority mismatch", func(t *testing.T) {
filter := &target.Filter{Priority: target.Rules{Include: []string{report.ErrorPriority.String()}}} filter := &target.Filter{Priority: filter.Rules{Include: []string{report.ErrorPriority.String()}}}
if filter.Validate(result) { if filter.Validate(result) {
t.Errorf("Unexpected Validation Result") t.Errorf("Unexpected Validation Result")
@ -108,28 +129,28 @@ func Test_BaseClient(t *testing.T) {
}) })
t.Run("Validate Exclude Policy match", func(t *testing.T) { t.Run("Validate Exclude Policy match", func(t *testing.T) {
filter := &target.Filter{Policy: target.Rules{Exclude: []string{"require-requests-and-limits-required"}}} filter := &target.Filter{Policy: filter.Rules{Exclude: []string{"require-requests-and-limits-required"}}}
if filter.Validate(result) { if filter.Validate(result) {
t.Errorf("Unexpected Validation Result") t.Errorf("Unexpected Validation Result")
} }
}) })
t.Run("Validate Exclude Policy mismatch", func(t *testing.T) { t.Run("Validate Exclude Policy mismatch", func(t *testing.T) {
filter := &target.Filter{Policy: target.Rules{Exclude: []string{"policy-test"}}} filter := &target.Filter{Policy: filter.Rules{Exclude: []string{"policy-test"}}}
if !filter.Validate(result) { if !filter.Validate(result) {
t.Errorf("Unexpected Validation Result") t.Errorf("Unexpected Validation Result")
} }
}) })
t.Run("Validate Include Policy match", func(t *testing.T) { t.Run("Validate Include Policy match", func(t *testing.T) {
filter := &target.Filter{Policy: target.Rules{Include: []string{"require-requests-and-limits-required"}}} filter := &target.Filter{Policy: filter.Rules{Include: []string{"require-requests-and-limits-required"}}}
if !filter.Validate(result) { if !filter.Validate(result) {
t.Errorf("Unexpected Validation Result") t.Errorf("Unexpected Validation Result")
} }
}) })
t.Run("Validate Exclude Policy mismatch", func(t *testing.T) { t.Run("Validate Exclude Policy mismatch", func(t *testing.T) {
filter := &target.Filter{Policy: target.Rules{Include: []string{"policy-test"}}} filter := &target.Filter{Policy: filter.Rules{Include: []string{"policy-test"}}}
if filter.Validate(result) { if filter.Validate(result) {
t.Errorf("Unexpected Validation Result") t.Errorf("Unexpected Validation Result")

311
templates/summary.html Normal file
View file

@ -0,0 +1,311 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style type="text/css">
body,table,td{font-family:Helvetica,Arial,sans-serif !important}.ExternalClass{width:100%}.ExternalClass,.ExternalClass p,.ExternalClass span,.ExternalClass font,.ExternalClass td,.ExternalClass div{line-height:150%}a{text-decoration:none}*{color:inherit}a[x-apple-data-detectors],u+#body a,#MessageViewBody a{color:inherit;text-decoration:none;font-size:inherit;font-family:inherit;font-weight:inherit;line-height:inherit}img{-ms-interpolation-mode:bicubic}table:not([class^=s-]){font-family:Helvetica,Arial,sans-serif;mso-table-lspace:0pt;mso-table-rspace:0pt;border-spacing:0px;border-collapse:collapse}table:not([class^=s-]) td{border-spacing:0px;border-collapse:collapse}@media screen and (max-width: 600px){.row-responsive.row{margin-right:0 !important}td.col-lg-3{display:block;width:100% !important;padding-left:0 !important;padding-right:0 !important}.w-full,.w-full>tbody>tr>td{width:100% !important}.p-4:not(table),.p-4:not(.btn)>tbody>tr>td,.p-4.btn td a{padding:16px !important}.pt-6:not(table),.pt-6:not(.btn)>tbody>tr>td,.pt-6.btn td a,.py-6:not(table),.py-6:not(.btn)>tbody>tr>td,.py-6.btn td a{padding-top:24px !important}*[class*=s-lg-]>tbody>tr>td{font-size:0 !important;line-height:0 !important;height:0 !important}.s-0>tbody>tr>td{font-size:0 !important;line-height:0 !important;height:0 !important}.s-4>tbody>tr>td{font-size:16px !important;line-height:16px !important;height:16px !important}.s-6>tbody>tr>td{font-size:24px !important;line-height:24px !important;height:24px !important}.s-8>tbody>tr>td{font-size:32px !important;line-height:32px !important;height:32px !important}}
</style>
</head>
<body class="bg-light" style="outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;" bgcolor="#f7fafc">
<table class="bg-light body" valign="top" role="presentation" border="0" cellpadding="0" cellspacing="0" style="outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;" bgcolor="#f7fafc">
<tbody>
<tr>
<td valign="top" style="line-height: 24px; font-size: 16px; margin: 0;" align="left" bgcolor="#f7fafc">
<table class="container-fluid" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 0 16px;" align="left">
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
{{ if .ClusterName }}
<h1 class="h1" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 36px; line-height: 43.2px; margin: 0;" align="left">{{ .ClusterName}}: Summary Report</h1>
{{ else }}
<h1 class="h1" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 36px; line-height: 43.2px; margin: 0;" align="left">Summary Report</h1>
{{ end }}
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
{{ range $key, $source := .Sources }}
<table class="card p-4 pt-6 my-4" role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-radius: 6px; border-collapse: separate !important; width: 100%; overflow: hidden; border: 1px solid #e2e8f0;" bgcolor="#ffffff">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 24px 16px 16px;" align="left" bgcolor="#ffffff">
<h2 class="h2" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 32px; line-height: 38.4px; margin: 0;" align="left">{{ $source.Name }}</h2>
<table class="s-6 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 24px; width: 100%; height: 24px; margin: 0;" align="left" width="100%" height="24">
&#160;
</td>
</tr>
</tbody>
</table>
<h3 class="h4" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="left">ClusterPolicyReport Summary</h3>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
{{ if $source.ClusterReports }}
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="hr" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" align="left">
</td>
</tr>
</tbody>
</table>
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="container-fluid" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 0 16px;" align="left">
<div class="row row-responsive" style="margin-right: -24px;">
<table class="" role="presentation" border="0" cellpadding="0" cellspacing="0" style="table-layout: fixed; width: 100%;" width="100%">
<tbody>
<tr>
<td class="col-lg-3 col-6" style="line-height: 24px; font-size: 16px; min-height: 1px; font-weight: normal; padding-right: 24px; width: 50%; margin: 0;" align="left" valign="top">
<table class="p-4" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 16px;" align="left">
<div class="space-y-4">
<h1 class="h4 fw-500 text-green-500 text-center" style="color: #198754; padding-top: 0; padding-bottom: 0; font-weight: 500 !important; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="center">
Pass
</h1>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<p class="text-4xl text-center" style="line-height: 43.2px; font-size: 36px; width: 100%; margin: 0;" align="center">{{ $source.ClusterScopeSummary.Pass }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</td>
<td class="col-lg-3 col-6" style="line-height: 24px; font-size: 16px; min-height: 1px; font-weight: normal; padding-right: 24px; width: 50%; margin: 0;" align="left" valign="top">
<table class="p-4" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 16px;" align="left">
<div class="space-y-4">
<h1 class="h4 fw-500 text-orange-500 text-center" style="color: #fd7e14; padding-top: 0; padding-bottom: 0; font-weight: 500 !important; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="center">
Warning
</h1>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<p class="text-4xl text-center" style="line-height: 43.2px; font-size: 36px; width: 100%; margin: 0;" align="center">{{ $source.ClusterScopeSummary.Warn }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</td>
<td class="col-lg-3 col-6" style="line-height: 24px; font-size: 16px; min-height: 1px; font-weight: normal; padding-right: 24px; width: 50%; margin: 0;" align="left" valign="top">
<table class="p-4" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 16px;" align="left">
<div class="space-y-4">
<h1 class="h4 fw-500 text-red-500 text-center" style="color: #dc3545; padding-top: 0; padding-bottom: 0; font-weight: 500 !important; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="center">
Fail
</h1>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<p class="text-4xl text-center" style="line-height: 43.2px; font-size: 36px; width: 100%; margin: 0;" align="center">{{ $source.ClusterScopeSummary.Fail }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</td>
<td class="col-lg-3 col-6" style="line-height: 24px; font-size: 16px; min-height: 1px; font-weight: normal; padding-right: 24px; width: 50%; margin: 0;" align="left" valign="top">
<table class="p-4" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 16px;" align="left">
<div class="space-y-4">
<h1 class="h4 fw-500 text-red-600 text-center" style="color: #b02a37; padding-top: 0; padding-bottom: 0; font-weight: 500 !important; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="center">
Error
</h1>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<p class="text-4xl text-center" style="line-height: 43.2px; font-size: 36px; width: 100%; margin: 0;" align="center">{{ $source.ClusterScopeSummary.Error }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
{{ end }}
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="hr" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" align="left">
</td>
</tr>
</tbody>
</table>
<table class="s-8 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 32px; font-size: 32px; width: 100%; height: 32px; margin: 0;" align="left" width="100%" height="32">
&#160;
</td>
</tr>
</tbody>
</table>
{{if $source.NamespaceScopeSummary }}
<h3 class="h4" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="left">PolicyReport Summary</h3>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="table table-striped thead-default table-bordered" border="0" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 100%; border: 1px solid #e2e8f0;">
<thead>
<tr>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Namespace</th>
<th class="text-right text-green-500" style="line-height: 24px; font-size: 16px; color: #198754; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="right" valign="top">Pass</th>
<th class="text-right text-orange-500" style="line-height: 24px; font-size: 16px; color: #fd7e14; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="right" valign="top">Warning</th>
<th class="text-right text-red-500" style="line-height: 24px; font-size: 16px; color: #dc3545; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="right" valign="top">Fail</th>
<th class="text-right text-red-600" style="line-height: 24px; font-size: 16px; color: #b02a37; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="right" valign="top">Error</th>
</tr>
</thead>
<tbody>
{{ range $ns, $sum := $source.NamespaceScopeSummary }}
<tr style="" bgcolor="#f2f2f2">
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $ns }}</td>
<td class="text-right" style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="right" valign="top">{{ $sum.Pass }}</td>
<td class="text-right" style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="right" valign="top">{{ $sum.Warn }}</td>
<td class="text-right" style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="right" valign="top">{{ $sum.Fail }}</td>
<td class="text-right" style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="right" valign="top">{{ $sum.Error }}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</td>
</tr>
</tbody>
</table>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
{{ end }}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

458
templates/violations.html Normal file
View file

@ -0,0 +1,458 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style type="text/css">
body,table,td{font-family:Helvetica,Arial,sans-serif !important}.ExternalClass{width:100%}.ExternalClass,.ExternalClass p,.ExternalClass span,.ExternalClass font,.ExternalClass td,.ExternalClass div{line-height:150%}a{text-decoration:none}*{color:inherit}a[x-apple-data-detectors],u+#body a,#MessageViewBody a{color:inherit;text-decoration:none;font-size:inherit;font-family:inherit;font-weight:inherit;line-height:inherit}img{-ms-interpolation-mode:bicubic}table:not([class^=s-]){font-family:Helvetica,Arial,sans-serif;mso-table-lspace:0pt;mso-table-rspace:0pt;border-spacing:0px;border-collapse:collapse}table:not([class^=s-]) td{border-spacing:0px;border-collapse:collapse}@media screen and (max-width: 600px){.row-responsive.row{margin-right:0 !important}td.col-lg-3{display:block;width:100% !important;padding-left:0 !important;padding-right:0 !important}.w-full,.w-full>tbody>tr>td{width:100% !important}.p-4:not(table),.p-4:not(.btn)>tbody>tr>td,.p-4.btn td a{padding:16px !important}.pt-6:not(table),.pt-6:not(.btn)>tbody>tr>td,.pt-6.btn td a,.py-6:not(table),.py-6:not(.btn)>tbody>tr>td,.py-6.btn td a{padding-top:24px !important}*[class*=s-lg-]>tbody>tr>td{font-size:0 !important;line-height:0 !important;height:0 !important}.s-0>tbody>tr>td{font-size:0 !important;line-height:0 !important;height:0 !important}.s-2>tbody>tr>td{font-size:8px !important;line-height:8px !important;height:8px !important}.s-4>tbody>tr>td{font-size:16px !important;line-height:16px !important;height:16px !important}.s-6>tbody>tr>td{font-size:24px !important;line-height:24px !important;height:24px !important}.s-8>tbody>tr>td{font-size:32px !important;line-height:32px !important;height:32px !important}}
</style>
</head>
<body class="bg-light" style="outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;" bgcolor="#f7fafc">
<table class="bg-light body" valign="top" role="presentation" border="0" cellpadding="0" cellspacing="0" style="outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;" bgcolor="#f7fafc">
<tbody>
<tr>
<td valign="top" style="line-height: 24px; font-size: 16px; margin: 0;" align="left" bgcolor="#f7fafc">
<table class="container-fluid" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 0 16px;" align="left">
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
{{ if .ClusterName }}
<h1 class="h1" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 36px; line-height: 43.2px; margin: 0;" align="left">{{ .ClusterName}}: Summary Report</h1>
{{ else }}
<h1 class="h1" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 36px; line-height: 43.2px; margin: 0;" align="left">Summary Report</h1>
{{ end }}
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
{{ range $key, $source := .Sources }}
<table class="card p-4 pt-6" role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-radius: 6px; border-collapse: separate !important; width: 100%; overflow: hidden; border: 1px solid #e2e8f0;" bgcolor="#ffffff">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 24px 16px 16px;" align="left" bgcolor="#ffffff">
<h2 class="h2" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 32px; line-height: 38.4px; margin: 0;" align="left">{{ $source.Name }}</h2>
<table class="s-6 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 24px; width: 100%; height: 24px; margin: 0;" align="left" width="100%" height="24">
&#160;
</td>
</tr>
</tbody>
</table>
{{ if $source.ClusterReports }}
<h3 class="h4" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="left">ClusterPolicyReport Summary</h3>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="hr" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" align="left">
</td>
</tr>
</tbody>
</table>
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<h4 class="h4 text-green-500" style="color: #198754; padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="left">Pass Results: {{ $source.ClusterPassed }}</h4>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="hr" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" align="left">
</td>
</tr>
</tbody>
</table>
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
{{ range $status := $.Status }}
{{ $results := index $source.ClusterResults $status }}
{{ $length := len $results }}
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<h4 class="h4 text-orange-500" style="color: {{ color $status }}; padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="left">{{ title $status }} Results: {{ $length }} </h4>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
{{ if $results }}
<table class="table table-striped thead-default table-bordered" border="0" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 100%; border: 1px solid #e2e8f0;">
<thead>
<tr>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Kind</th>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Name</th>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Policy</th>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Valdiation Rule</th>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Result</th>
</tr>
</thead>
<tbody>
{{ range $key, $result := $results }}
<tr style="" bgcolor="#f2f2f2">
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Kind }}</td>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Name }}</td>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Policy }}</td>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Rule }}</td>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Status }}</td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="hr" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" align="left">
</td>
</tr>
</tbody>
</table>
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
{{ end }}
{{ end }}
{{ end }}
{{ if or $source.NamespaceResults $source.NamespacePassed }}
<table class="s-8 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 32px; font-size: 32px; width: 100%; height: 32px; margin: 0;" align="left" width="100%" height="32">
&#160;
</td>
</tr>
</tbody>
</table>
<h3 class="h4" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="left">PolicyReport Summary</h3>
{{ end }}
{{ range $namespace, $list := $source.NamespaceResults }}
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height:32px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="32">
&#160;
</td>
</tr>
</tbody>
</table>
<h3 class="h5" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 20px; line-height: 24px; margin: 0;" align="left">Namespace: {{ $namespace }}</h3>
<table class="s-8 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 32px; font-size: 32px; width: 100%; height: 32px; margin: 0;" align="left" width="100%" height="32">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="card" role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-radius: 6px; border-collapse: separate !important; width: 100%; overflow: hidden; border: 1px solid #e2e8f0;" bgcolor="#ffffff">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0;" align="left" bgcolor="#ffffff">
<table class="container-fluid" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 0 16px;" align="left">
<div class="row row-responsive" style="margin-right: -24px;">
<table class="" role="presentation" border="0" cellpadding="0" cellspacing="0" style="table-layout: fixed; width: 100%;" width="100%">
<tbody>
<tr>
<td class="col-lg-3 col-6" style="line-height: 24px; font-size: 16px; min-height: 1px; font-weight: normal; padding-right: 24px; width: 50%; margin: 0;" align="left" valign="top">
<table class="p-4" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 16px;" align="left">
<div class="space-y-4">
<h1 class="h4 fw-500 text-green-500 text-center" style="color: #198754; padding-top: 0; padding-bottom: 0; font-weight: 500 !important; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="center">
Pass
</h1>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<p class="text-4xl text-center" style="line-height: 43.2px; font-size: 36px; width: 100%; margin: 0;" align="center">{{index $source.NamespacePassed $namespace }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</td>
<td class="col-lg-3 col-6" style="line-height: 24px; font-size: 16px; min-height: 1px; font-weight: normal; padding-right: 24px; width: 50%; margin: 0;" align="left" valign="top">
<table class="p-4" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 16px;" align="left">
<div class="space-y-4">
<h1 class="h4 fw-500 text-orange-500 text-center" style="color: #fd7e14; padding-top: 0; padding-bottom: 0; font-weight: 500 !important; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="center">
Warning
</h1>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<p class="text-4xl text-center" style="line-height: 43.2px; font-size: 36px; width: 100%; margin: 0;" align="center">{{ lenNamespaceResults $source $namespace "warn" }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</td>
<td class="col-lg-3 col-6" style="line-height: 24px; font-size: 16px; min-height: 1px; font-weight: normal; padding-right: 24px; width: 50%; margin: 0;" align="left" valign="top">
<table class="p-4" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 16px;" align="left">
<div class="space-y-4">
<h1 class="h4 fw-500 text-red-500 text-center" style="color: #dc3545; padding-top: 0; padding-bottom: 0; font-weight: 500 !important; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="center">
Fail
</h1>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<p class="text-4xl text-center" style="line-height: 43.2px; font-size: 36px; width: 100%; margin: 0;" align="center">{{ lenNamespaceResults $source $namespace "fail" }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</td>
<td class="col-lg-3 col-6" style="line-height: 24px; font-size: 16px; min-height: 1px; font-weight: normal; padding-right: 24px; width: 50%; margin: 0;" align="left" valign="top">
<table class="p-4" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 16px;" align="left">
<div class="space-y-4">
<h1 class="h4 fw-500 text-red-600 text-center" style="color: #b02a37; padding-top: 0; padding-bottom: 0; font-weight: 500 !important; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="center">
Error
</h1>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<p class="text-4xl text-center" style="line-height: 43.2px; font-size: 36px; width: 100%; margin: 0;" align="center">{{ lenNamespaceResults $source $namespace "error" }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table class="s-2 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 8px; font-size: 8px; width: 100%; height: 8px; margin: 0;" align="left" width="100%" height="8">
&#160;
</td>
</tr>
</tbody>
</table>
{{ if hasViolations $list }}
<table class="table table-striped thead-default table-bordered" border="0" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 100%; border: 1px solid #e2e8f0;">
<thead>
<tr>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Kind</th>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Name</th>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Policy</th>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Valdiation Rule</th>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Result</th>
</tr>
</thead>
<tbody>
{{ range $status, $results := $list }}
{{ range $key, $result := $results }}
<tr style="" bgcolor="#f2f2f2">
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Kind }}</td>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Name }}</td>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Policy }}</td>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Rule }}</td>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">
<table class="badge bg-orange-500 text-white" align="left" role="presentation" border="0" cellpadding="0" cellspacing="0" style="color: #ffffff;" bgcolor="{{ color $status }}">
<tbody>
<tr>
<td style="line-height: 1; font-size: 75%; display: inline-block; font-weight: 700; white-space: nowrap; border-radius: 4px; color: #ffffff; margin: 0; padding: 4px 6.4px;" align="center" bgcolor="{{ color $status }}" valign="baseline">
<span>{{ $result.Status }}</span>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
{{end}}
{{end}}
</tbody>
</table>
{{end}}
{{end}}
</td>
</tr>
</tbody>
</table>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
{{ end }}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>