From 91f2b48187c5ccdc662b25906739f79df298fbfc Mon Sep 17 00:00:00 2001 From: Frank Jogeleit Date: Thu, 11 Jan 2024 13:09:58 +0100 Subject: [PATCH] Plugin Support Signed-off-by: Frank Jogeleit --- backend/cmd/run.go | 2 +- backend/pkg/{core/utils => api}/certs.go | 2 +- backend/pkg/api/client.go | 68 +++++ backend/pkg/api/core/client.go | 151 ++++++++++++ .../pkg/{core/client => api/core}/model.go | 10 +- backend/pkg/api/decoder.go | 27 ++ backend/pkg/api/http.go | 25 ++ .../{core/client => api}/logroundtripper.go | 4 +- backend/pkg/{core/client => api}/options.go | 13 +- backend/pkg/api/plugin/client.go | 46 ++++ backend/pkg/api/plugin/model.go | 56 +++++ backend/pkg/{core => api}/proxy/proxy.go | 4 +- backend/pkg/config/config.go | 33 ++- backend/pkg/config/resolver.go | 109 +++++--- backend/pkg/core/client/client.go | 232 ------------------ backend/pkg/server/api/handler.go | 102 +++++--- backend/pkg/server/api/mapper.go | 100 +++++++- backend/pkg/server/api/model.go | 11 + backend/pkg/server/server.go | 26 +- backend/pkg/service/mapper.go | 18 +- backend/pkg/service/model.go | 40 ++- backend/pkg/service/service.go | 121 +++++++-- frontend/app.vue | 6 +- frontend/bun.lockb | Bin 303069 -> 318305 bytes frontend/components/CollapseBtn.vue | 4 +- frontend/layouts/default.vue | 21 +- frontend/modules/core/api.ts | 14 +- .../modules/core/components/PropertyCard.vue | 2 +- frontend/modules/core/components/Results.vue | 4 +- .../core/components/policy/Details.vue | 147 +++++++++++ .../modules/core/components/policy/Item.vue | 15 +- .../modules/core/components/policy/List.vue | 33 +-- .../core/components/policy/SourceGroup.vue | 36 ++- .../core/components/policy/StatusCharts.vue | 2 +- .../core/components/resource/Results.vue | 7 +- frontend/modules/core/types.ts | 25 +- frontend/package.json | 2 + frontend/pages/policies/[source]/[policy].vue | 34 +-- frontend/pages/policies/[source]/index.vue | 28 +++ .../pages/policies/[source]/info/[policy].vue | 41 ++++ frontend/plugins/04.highlight.client.ts | 13 + 41 files changed, 1205 insertions(+), 429 deletions(-) rename backend/pkg/{core/utils => api}/certs.go (94%) create mode 100644 backend/pkg/api/client.go create mode 100644 backend/pkg/api/core/client.go rename backend/pkg/{core/client => api/core}/model.go (81%) create mode 100644 backend/pkg/api/decoder.go create mode 100644 backend/pkg/api/http.go rename backend/pkg/{core/client => api}/logroundtripper.go (91%) rename backend/pkg/{core/client => api}/options.go (83%) create mode 100644 backend/pkg/api/plugin/client.go create mode 100644 backend/pkg/api/plugin/model.go rename backend/pkg/{core => api}/proxy/proxy.go (95%) delete mode 100644 backend/pkg/core/client/client.go create mode 100644 frontend/modules/core/components/policy/Details.vue create mode 100644 frontend/pages/policies/[source]/index.vue create mode 100644 frontend/pages/policies/[source]/info/[policy].vue create mode 100644 frontend/plugins/04.highlight.client.ts diff --git a/backend/cmd/run.go b/backend/cmd/run.go index cc631d0c..deda70d5 100644 --- a/backend/cmd/run.go +++ b/backend/cmd/run.go @@ -47,7 +47,7 @@ func newRunCMD() *cobra.Command { cmd.Flags().IntVar(&c.Server.Port, "port", 8080, "PolicyReporter UI port") cmd.Flags().BoolVar(&c.Server.CORS, "dev", false, "Enable CORS Header for development") cmd.Flags().BoolVar(&c.UI.Disabled, "no-ui", false, "Disable the embedded frontend") - cmd.Flags().BoolVar(&c.Cluster, "cluster", false, "use kube config to connect to cluster") + cmd.Flags().BoolVar(&c.Local, "local", false, "use kube config to connect to cluster") flag.Parse() return cmd diff --git a/backend/pkg/core/utils/certs.go b/backend/pkg/api/certs.go similarity index 94% rename from backend/pkg/core/utils/certs.go rename to backend/pkg/api/certs.go index 06613a69..12fe4000 100644 --- a/backend/pkg/core/utils/certs.go +++ b/backend/pkg/api/certs.go @@ -1,4 +1,4 @@ -package utils +package api import ( "crypto/x509" diff --git a/backend/pkg/api/client.go b/backend/pkg/api/client.go new file mode 100644 index 00000000..f00c0a16 --- /dev/null +++ b/backend/pkg/api/client.go @@ -0,0 +1,68 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/url" +) + +type Client struct { + baseURL string + http *http.Client + auth *BasicAuth +} + +func (c *Client) Post(ctx context.Context, path string, payload any) (*http.Response, error) { + body := new(bytes.Buffer) + + if err := json.NewEncoder(body).Encode(payload); err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+path, body) + if err != nil { + return nil, err + } + + if c.auth != nil { + req.SetBasicAuth(c.auth.Username, c.auth.Password) + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("User-Agent", "Policy Reporter UI") + + return c.http.Do(req) +} + +func (c *Client) Get(ctx context.Context, path string, query url.Values) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+path, nil) + if err != nil { + return nil, err + } + + if c.auth != nil { + req.SetBasicAuth(c.auth.Username, c.auth.Password) + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("User-Agent", "Policy Reporter UI") + req.URL.RawQuery = query.Encode() + + return c.http.Do(req) +} + +func New(options []ClientOption) (*Client, error) { + client := &Client{ + http: NewHTTPClient(), + } + + for _, o := range options { + if err := o(client); err != nil { + return nil, err + } + } + + return client, nil +} diff --git a/backend/pkg/api/core/client.go b/backend/pkg/api/core/client.go new file mode 100644 index 00000000..19cd68e9 --- /dev/null +++ b/backend/pkg/api/core/client.go @@ -0,0 +1,151 @@ +package core + +import ( + "context" + "fmt" + "net/url" + + "github.com/kyverno/policy-reporter-ui/pkg/api" +) + +type Client struct { + *api.Client +} + +func (c *Client) GetResource(ctx context.Context, id string) (*Resource, error) { + resp, err := c.Get(ctx, fmt.Sprintf("/v2/resource/%s", id), url.Values{}) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return api.Decode[Resource](resp.Body) +} + +func (c *Client) GetResourceStatusCounts(ctx context.Context, id string, query url.Values) ([]ResourceStatusCount, error) { + resp, err := c.Get(ctx, fmt.Sprintf("/v2/resource/%s/status-counts", id), query) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return api.DecodeList[ResourceStatusCount](resp.Body) +} + +func (c *Client) ListSourceCategoryTree(ctx context.Context, query url.Values) ([]SourceCategoryTree, error) { + resp, err := c.Get(ctx, "/v2/sources/categories", query) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return api.DecodeList[SourceCategoryTree](resp.Body) +} + +func (c *Client) ListResourceCategories(ctx context.Context, id string, query url.Values) ([]SourceCategoryTree, error) { + resp, err := c.Get(ctx, fmt.Sprintf("/v2/resource/%s/source-categories", id), query) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return api.DecodeList[SourceCategoryTree](resp.Body) +} + +func (c *Client) GetFindings(ctx context.Context, query url.Values) (*Findings, error) { + resp, err := c.Get(ctx, "/v2/findings", query) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return api.Decode[Findings](resp.Body) +} + +func (c *Client) GetNamespaceStatusCounts(ctx context.Context, source string, query url.Values) (NamespaceStatusCounts, error) { + resp, err := c.Get(ctx, fmt.Sprintf("/v2/namespace-scoped/%s/status-counts", source), query) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return api.DecodeMap[string, map[string]int](resp.Body) +} + +func (c *Client) GetClusterStatusCounts(ctx context.Context, source string, query url.Values) (map[string]int, error) { + resp, err := c.Get(ctx, fmt.Sprintf("/v2/cluster-scoped/%s/status-counts", source), query) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return api.DecodeMap[string, int](resp.Body) +} + +func (c *Client) ListSources(ctx context.Context, query url.Values) ([]string, error) { + resp, err := c.Get(ctx, "/v2/sources", query) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return api.DecodeList[string](resp.Body) +} + +func (c *Client) UseResources(ctx context.Context, source string, query url.Values) (bool, error) { + resp, err := c.Get(ctx, fmt.Sprintf("/v2/sources/%s/use-resources", source), query) + if err != nil { + return false, err + } + defer resp.Body.Close() + + result, err := api.DecodeMap[string, bool](resp.Body) + if err != nil { + return false, err + } + + return result["resources"], nil +} + +func (c *Client) ListNamespaces(ctx context.Context, query url.Values) ([]string, error) { + resp, err := c.Get(ctx, "/v1/namespaces", query) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return api.DecodeList[string](resp.Body) +} + +func (c *Client) ListPolicies(ctx context.Context, query url.Values) ([]Policy, error) { + resp, err := c.Get(ctx, "/v2/policies", query) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return api.DecodeList[Policy](resp.Body) +} + +func (c *Client) ResolveNamespaceSelector(ctx context.Context, selector map[string]string) ([]string, error) { + resp, err := c.Post(ctx, "/v2/namespaces/resolve-selector", selector) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return api.DecodeList[string](resp.Body) +} + +func New(options []api.ClientOption) (*Client, error) { + baseClient, err := api.New(options) + if err != nil { + return nil, err + } + + client := &Client{ + Client: baseClient, + } + + return client, nil +} diff --git a/backend/pkg/core/client/model.go b/backend/pkg/api/core/model.go similarity index 81% rename from backend/pkg/core/client/model.go rename to backend/pkg/api/core/model.go index fbb03dd4..dde76f4f 100644 --- a/backend/pkg/core/client/model.go +++ b/backend/pkg/api/core/model.go @@ -1,4 +1,4 @@ -package client +package core type Category struct { Name string `json:"name"` @@ -9,6 +9,14 @@ type Category struct { Fail int `json:"fail"` } +type Policy struct { + Source string `json:"source,omitempty"` + Category string `json:"category,omitempty"` + Name string `json:"policy"` + Severity string `json:"severity,omitempty"` + Results map[string]int `json:"results"` +} + type Resource struct { ID string `json:"id,omitempty"` UID string `json:"uid,omitempty"` diff --git a/backend/pkg/api/decoder.go b/backend/pkg/api/decoder.go new file mode 100644 index 00000000..97f4072e --- /dev/null +++ b/backend/pkg/api/decoder.go @@ -0,0 +1,27 @@ +package api + +import ( + "encoding/json" + "io" +) + +func DecodeList[T any](r io.Reader) ([]T, error) { + list := make([]T, 0) + err := json.NewDecoder(r).Decode(&list) + + return list, err +} + +func DecodeMap[R comparable, T any](r io.Reader) (map[R]T, error) { + mapping := make(map[R]T) + err := json.NewDecoder(r).Decode(&mapping) + + return mapping, err +} + +func Decode[T any](r io.Reader) (*T, error) { + model := new(T) + err := json.NewDecoder(r).Decode(model) + + return model, err +} diff --git a/backend/pkg/api/http.go b/backend/pkg/api/http.go new file mode 100644 index 00000000..50fdb757 --- /dev/null +++ b/backend/pkg/api/http.go @@ -0,0 +1,25 @@ +package api + +import ( + "crypto/tls" + "net" + "net/http" + "time" +) + +func NewHTTPClient() *http.Client { + return &http.Client{ + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 60 * time.Second, + }).DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + TLSClientConfig: &tls.Config{}, + }, + Timeout: 10 * time.Second, + } +} diff --git a/backend/pkg/core/client/logroundtripper.go b/backend/pkg/api/logroundtripper.go similarity index 91% rename from backend/pkg/core/client/logroundtripper.go rename to backend/pkg/api/logroundtripper.go index 4a099fec..0233a7db 100644 --- a/backend/pkg/core/client/logroundtripper.go +++ b/backend/pkg/api/logroundtripper.go @@ -1,4 +1,4 @@ -package client +package api import ( "fmt" @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" ) -func newLoggingRoundTripper(roundTripper http.RoundTripper) http.RoundTripper { +func NewLoggingRoundTripper(roundTripper http.RoundTripper) http.RoundTripper { return &logRoundTripper{roundTripper: roundTripper} } diff --git a/backend/pkg/core/client/options.go b/backend/pkg/api/options.go similarity index 83% rename from backend/pkg/core/client/options.go rename to backend/pkg/api/options.go index 4ab87082..e7e1547a 100644 --- a/backend/pkg/core/client/options.go +++ b/backend/pkg/api/options.go @@ -1,12 +1,15 @@ -package client +package api import ( "fmt" "net/http" - - "github.com/kyverno/policy-reporter-ui/pkg/core/utils" ) +type BasicAuth struct { + Username string + Password string +} + type ClientOption = func(*Client) error func WithBaseURL(url string) ClientOption { @@ -27,7 +30,7 @@ func WithBaseAuth(auth BasicAuth) ClientOption { func WithCertificate(path string) ClientOption { return func(client *Client) error { - certs, err := utils.LoadCerts(path) + certs, err := LoadCerts(path) if err != nil { return fmt.Errorf("with certificate failed: %w", err) } @@ -48,7 +51,7 @@ func WithSkipTLS() ClientOption { func WithLogging() ClientOption { return func(client *Client) error { - client.http.Transport = newLoggingRoundTripper(client.http.Transport) + client.http.Transport = NewLoggingRoundTripper(client.http.Transport) return nil } diff --git a/backend/pkg/api/plugin/client.go b/backend/pkg/api/plugin/client.go new file mode 100644 index 00000000..10bc3304 --- /dev/null +++ b/backend/pkg/api/plugin/client.go @@ -0,0 +1,46 @@ +package plugin + +import ( + "context" + "fmt" + "net/url" + + "github.com/kyverno/policy-reporter-ui/pkg/api" +) + +type Client struct { + *api.Client +} + +func (c *Client) ListPolicies(ctx context.Context, query url.Values) ([]Policy, error) { + resp, err := c.Get(ctx, "/policies", query) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return api.DecodeList[Policy](resp.Body) +} + +func (c *Client) GetPolicy(ctx context.Context, name string) (*PolicyDetails, error) { + resp, err := c.Get(ctx, fmt.Sprintf("/policies/%s", name), url.Values{}) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return api.Decode[PolicyDetails](resp.Body) +} + +func New(options []api.ClientOption) (*Client, error) { + baseClient, err := api.New(options) + if err != nil { + return nil, err + } + + client := &Client{ + Client: baseClient, + } + + return client, nil +} diff --git a/backend/pkg/api/plugin/model.go b/backend/pkg/api/plugin/model.go new file mode 100644 index 00000000..582f9983 --- /dev/null +++ b/backend/pkg/api/plugin/model.go @@ -0,0 +1,56 @@ +package plugin + +import "fmt" + +type Policy struct { + Category string `json:"category"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + Severity string `json:"severity,omitempty"` +} + +func (p Policy) ID() string { + if p.Namespace == "" { + return p.Name + } + + return fmt.Sprintf("%s/%s", p.Namespace, p.Name) +} + +type Engine struct { + Name string `json:"name"` + KubernetesVersion string `json:"kubernetesVersion,omitempty"` + Version string `json:"version,omitempty"` + Subjects []string `json:"subjects,omitempty"` +} + +type SourceCode struct { + ContentType string `json:"contentType"` + Content string `json:"content"` +} + +type Item struct { + Title string `json:"title"` + Value string `json:"value"` +} + +type Details struct { + Title string `json:"title"` + Items []Item `json:"items"` +} + +type PolicyDetails struct { + Category string `json:"category"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + Severity string `json:"severity,omitempty"` + Engine Engine `json:"engine,omitempty"` + SourceCode SourceCode `json:"code,omitempty"` + Additional []Item `json:"additional"` + Details []Details `json:"details,omitempty"` + References []string `json:"references,omitempty"` +} diff --git a/backend/pkg/core/proxy/proxy.go b/backend/pkg/api/proxy/proxy.go similarity index 95% rename from backend/pkg/core/proxy/proxy.go rename to backend/pkg/api/proxy/proxy.go index 9395100a..ff38e06f 100644 --- a/backend/pkg/core/proxy/proxy.go +++ b/backend/pkg/api/proxy/proxy.go @@ -8,7 +8,7 @@ import ( "net/url" "time" - "github.com/kyverno/policy-reporter-ui/pkg/core/utils" + "github.com/kyverno/policy-reporter-ui/pkg/api" "go.uber.org/zap" ) @@ -48,7 +48,7 @@ func WithAuth(username, password string) DirectorOption { func WithCertificate(certificatePath string) ProxyOption { return func(proxy *httputil.ReverseProxy) { - pool, err := utils.LoadCerts(certificatePath) + pool, err := api.LoadCerts(certificatePath) if err != nil { zap.L().Error("failed to read certificate", zap.Error(err), zap.String("path", certificatePath)) return diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index e1ae9507..29c96b0c 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -40,8 +40,32 @@ func (a OAuth) FromValues(values secrets.Values) OAuth { } type Plugin struct { - Name string `mapstructure:"name"` - Host string `mapstructure:"host"` + Name string `mapstructure:"name"` + Host string `mapstructure:"host"` + SkipTLS bool `mapstructure:"skipTLS"` + Certificate string `mapstructure:"certificate"` + SecretRef string `mapstructure:"secretRef"` + BasicAuth BasicAuth `mapstructure:"basicAuth"` +} + +func (a Plugin) FromValues(values secrets.Values) Plugin { + if values.Host != "" { + a.Host = values.Host + } + if values.Certificate != "" { + a.Certificate = values.Certificate + } + if values.SkipTLS { + a.SkipTLS = values.SkipTLS + } + if values.Username != "" { + a.BasicAuth.Username = values.Username + } + if values.Password != "" { + a.BasicAuth.Password = values.Password + } + + return a } // APISetup configuration @@ -59,9 +83,6 @@ func (a Cluster) FromValues(values secrets.Values) Cluster { if values.Host != "" { a.Host = values.Host } - if values.KyvernoAPI != "" { - a.Plugins = append(a.Plugins, Plugin{Name: "kyverno", Host: values.KyvernoAPI}) - } if values.Certificate != "" { a.Certificate = values.Certificate } @@ -135,5 +156,5 @@ type Config struct { Redis redis.Config `mapstructure:"redis"` OAuth OAuth `mapstructure:"oauth"` CustomBoards []CustomBoard `mapstructure:"customBoards"` - Cluster bool `mapstructure:"cluster"` + Local bool `mapstructure:"local"` } diff --git a/backend/pkg/config/resolver.go b/backend/pkg/config/resolver.go index c636a5fc..276f2731 100644 --- a/backend/pkg/config/resolver.go +++ b/backend/pkg/config/resolver.go @@ -12,9 +12,11 @@ import ( k8s "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" + "github.com/kyverno/policy-reporter-ui/pkg/api" + "github.com/kyverno/policy-reporter-ui/pkg/api/core" + "github.com/kyverno/policy-reporter-ui/pkg/api/plugin" + "github.com/kyverno/policy-reporter-ui/pkg/api/proxy" "github.com/kyverno/policy-reporter-ui/pkg/auth" - "github.com/kyverno/policy-reporter-ui/pkg/core/client" - "github.com/kyverno/policy-reporter-ui/pkg/core/proxy" "github.com/kyverno/policy-reporter-ui/pkg/kubernetes/secrets" "github.com/kyverno/policy-reporter-ui/pkg/logging" "github.com/kyverno/policy-reporter-ui/pkg/server" @@ -37,29 +39,54 @@ type Resolver struct { clientset *k8s.Clientset } -func (r *Resolver) CoreClient(cluster Cluster) (*client.Client, error) { - options := []client.ClientOption{ - client.WithBaseURL(cluster.Host), +func (r *Resolver) CoreClient(cluster Cluster) (*core.Client, error) { + options := []api.ClientOption{ + api.WithBaseURL(cluster.Host), } if cluster.Certificate != "" { - options = append(options, client.WithCertificate(cluster.Certificate)) + options = append(options, api.WithCertificate(cluster.Certificate)) } else if cluster.SkipTLS { - options = append(options, client.WithSkipTLS()) + options = append(options, api.WithSkipTLS()) } if cluster.BasicAuth.Username != "" { - options = append(options, client.WithBaseAuth(client.BasicAuth{ + options = append(options, api.WithBaseAuth(api.BasicAuth{ Username: cluster.BasicAuth.Username, Password: cluster.BasicAuth.Password, })) } if r.config.Logging.Enabled { - options = append(options, client.WithLogging()) + options = append(options, api.WithLogging()) } - return client.New(options) + return core.New(options) +} + +func (r *Resolver) PluginClient(p Plugin) (*plugin.Client, error) { + options := []api.ClientOption{ + api.WithBaseURL(p.Host), + } + + if p.Certificate != "" { + options = append(options, api.WithCertificate(p.Certificate)) + } else if p.SkipTLS { + options = append(options, api.WithSkipTLS()) + } + + if p.BasicAuth.Username != "" { + options = append(options, api.WithBaseAuth(api.BasicAuth{ + Username: p.BasicAuth.Username, + Password: p.BasicAuth.Password, + })) + } + + if r.config.Logging.Enabled { + options = append(options, api.WithLogging()) + } + + return plugin.New(options) } func (r *Resolver) LoadClusterSecret(ctx context.Context, cluster Cluster) (Cluster, error) { @@ -75,7 +102,20 @@ func (r *Resolver) LoadClusterSecret(ctx context.Context, cluster Cluster) (Clus return cluster, nil } -func (r *Resolver) ExternalProxies(cluster Cluster) (map[string]*httputil.ReverseProxy, error) { +func (r *Resolver) LoadPluginSecret(ctx context.Context, plugin Plugin) (Plugin, error) { + if plugin.SecretRef != "" { + values, err := r.LoadSecret(ctx, plugin.SecretRef) + if err != nil { + return plugin, err + } + + plugin = plugin.FromValues(values) + } + + return plugin, nil +} + +func (r *Resolver) Proxies(cluster Cluster) (*httputil.ReverseProxy, error) { if cluster.Host == "" { return nil, ErrMissingAPI } @@ -109,21 +149,7 @@ func (r *Resolver) ExternalProxies(cluster Cluster) (map[string]*httputil.Revers proxyOptions = append(proxyOptions, proxy.WithCertificate(cluster.Certificate)) } - proxies := map[string]*httputil.ReverseProxy{ - "core": proxy.New(target, options, proxyOptions), - } - - for _, p := range cluster.Plugins { - pluginTarget, err := url.Parse(p.Host) - if err != nil { - zap.L().Error("failed to parse plugin host", zap.String("plugin", p.Name), zap.String("host", p.Host), zap.Error(err)) - continue - } - - proxies[p.Name] = proxy.New(pluginTarget, options, proxyOptions) - } - - return proxies, nil + return proxy.New(target, options, proxyOptions), nil } func (r *Resolver) LoadBasicAuth(ctx context.Context, secretRef string) (*BasicAuth, error) { @@ -170,7 +196,7 @@ func (r *Resolver) K8sConfig() (*rest.Config, error) { var k8sConfig *rest.Config var err error - if r.config.Cluster { + if r.config.Local { k8sConfig, err = utils.RestConfig(r.config.KubeConfig) } else { k8sConfig, err = rest.InClusterConfig() @@ -257,11 +283,11 @@ func (r *Resolver) Server(ctx context.Context) (*server.Server, error) { for _, cluster := range r.config.Clusters { cluster, err := r.LoadClusterSecret(ctx, cluster) if err != nil { - zap.L().Error("failed to load cluster secret", zap.Error(err), zap.String("cluser", cluster.Name)) + zap.L().Error("failed to load cluster secret", zap.Error(err), zap.String("cluser", cluster.Name), zap.String("secretRef", cluster.SecretRef)) continue } - proxies, err := r.ExternalProxies(cluster) + proxy, err := r.Proxies(cluster) if err != nil { zap.L().Error("failed to resolve proxies", zap.Error(err), zap.String("cluser", cluster.Name)) continue @@ -269,7 +295,30 @@ func (r *Resolver) Server(ctx context.Context) (*server.Server, error) { client, err := r.CoreClient(cluster) - serv.RegisterCluster(cluster.Name, client, proxies) + plugins := make(map[string]*plugin.Client, len(cluster.Plugins)) + for _, p := range cluster.Plugins { + p, err := r.LoadPluginSecret(ctx, p) + if err != nil { + zap.L().Error( + "failed to load plugin secret", + zap.Error(err), + zap.String("cluster", cluster.Name), + zap.String("plugin", p.Name), + zap.String("secretRef", p.SecretRef), + ) + continue + } + + pClient, err := r.PluginClient(p) + if err != nil { + zap.L().Error("failed to create plugin client", zap.Error(err), zap.String("cluser", cluster.Name), zap.String("plugin", p.Name)) + continue + } + + plugins[p.Name] = pClient + } + + serv.RegisterCluster(cluster.Name, client, plugins, proxy) } if !r.config.UI.Disabled { diff --git a/backend/pkg/core/client/client.go b/backend/pkg/core/client/client.go deleted file mode 100644 index 2df634bc..00000000 --- a/backend/pkg/core/client/client.go +++ /dev/null @@ -1,232 +0,0 @@ -package client - -import ( - "bytes" - "context" - "crypto/tls" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "net/url" - "time" -) - -type BasicAuth struct { - Username string - Password string -} - -type Client struct { - baseURL string - http *http.Client - auth *BasicAuth -} - -func (c *Client) GetResource(ctx context.Context, id string) (*Resource, error) { - resp, err := c.get(ctx, fmt.Sprintf("/v2/resource/%s", id), url.Values{}) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - return decode[Resource](resp.Body) -} -func (c *Client) GetResourceStatusCounts(ctx context.Context, id string, query url.Values) ([]ResourceStatusCount, error) { - resp, err := c.get(ctx, fmt.Sprintf("/v2/resource/%s/status-counts", id), query) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - return decodeList[ResourceStatusCount](resp.Body) -} - -func (c *Client) ListSourceCategoryTree(ctx context.Context, query url.Values) ([]SourceCategoryTree, error) { - resp, err := c.get(ctx, "/v2/sources/categories", query) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - return decodeList[SourceCategoryTree](resp.Body) -} - -func (c *Client) ListResourceCategories(ctx context.Context, id string, query url.Values) ([]SourceCategoryTree, error) { - resp, err := c.get(ctx, fmt.Sprintf("/v2/resource/%s/source-categories", id), query) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - return decodeList[SourceCategoryTree](resp.Body) -} - -func (c *Client) GetFindings(ctx context.Context, query url.Values) (*Findings, error) { - resp, err := c.get(ctx, "/v2/findings", query) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - return decode[Findings](resp.Body) -} - -func (c *Client) GetNamespaceStatusCounts(ctx context.Context, source string, query url.Values) (NamespaceStatusCounts, error) { - resp, err := c.get(ctx, fmt.Sprintf("/v2/namespace-scoped/%s/status-counts", source), query) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - return decodeMap[string, map[string]int](resp.Body) -} - -func (c *Client) GetClusterStatusCounts(ctx context.Context, source string, query url.Values) (map[string]int, error) { - resp, err := c.get(ctx, fmt.Sprintf("/v2/cluster-scoped/%s/status-counts", source), query) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - return decodeMap[string, int](resp.Body) -} - -func (c *Client) ListSources(ctx context.Context, query url.Values) ([]string, error) { - resp, err := c.get(ctx, "/v2/sources", query) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - return decodeList[string](resp.Body) -} - -func (c *Client) UseResources(ctx context.Context, source string, query url.Values) (bool, error) { - resp, err := c.get(ctx, fmt.Sprintf("/v2/sources/%s/use-resources", source), query) - if err != nil { - return false, err - } - defer resp.Body.Close() - - result, err := decodeMap[string, bool](resp.Body) - if err != nil { - return false, err - } - - return result["resources"], nil -} - -func (c *Client) ListNamespaces(ctx context.Context, query url.Values) ([]string, error) { - resp, err := c.get(ctx, "/v1/namespaces", query) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - return decodeList[string](resp.Body) -} - -func (c *Client) ResolveNamespaceSelector(ctx context.Context, selector map[string]string) ([]string, error) { - resp, err := c.post(ctx, "/v2/namespaces/resolve-selector", selector) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - return decodeList[string](resp.Body) -} - -// CreateJSONRequest for the given configuration -func (c *Client) post(ctx context.Context, path string, payload any) (*http.Response, error) { - body := new(bytes.Buffer) - - if err := json.NewEncoder(body).Encode(payload); err != nil { - return nil, err - } - - req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+path, body) - if err != nil { - return nil, err - } - - if c.auth != nil { - req.SetBasicAuth(c.auth.Username, c.auth.Password) - } - - req.Header.Set("Content-Type", "application/json; charset=utf-8") - req.Header.Set("User-Agent", "Policy Reporter UI") - - return c.http.Do(req) -} - -// CreateJSONRequest for the given configuration -func (c *Client) get(ctx context.Context, path string, query url.Values) (*http.Response, error) { - req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+path, nil) - if err != nil { - return nil, err - } - - if c.auth != nil { - req.SetBasicAuth(c.auth.Username, c.auth.Password) - } - - req.Header.Set("Content-Type", "application/json; charset=utf-8") - req.Header.Set("User-Agent", "Policy Reporter UI") - req.URL.RawQuery = query.Encode() - - return c.http.Do(req) -} - -func decodeList[T any](r io.Reader) ([]T, error) { - list := make([]T, 0) - err := json.NewDecoder(r).Decode(&list) - - return list, err -} - -func decodeMap[R comparable, T any](r io.Reader) (map[R]T, error) { - mapping := make(map[R]T) - err := json.NewDecoder(r).Decode(&mapping) - - return mapping, err -} - -func decode[T any](r io.Reader) (*T, error) { - model := new(T) - err := json.NewDecoder(r).Decode(model) - - return model, err -} - -func New(options []ClientOption) (*Client, error) { - client := &Client{ - http: newHTTPClient(), - } - - for _, o := range options { - if err := o(client); err != nil { - return nil, err - } - } - - return client, nil -} - -func newHTTPClient() *http.Client { - return &http.Client{ - Transport: &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: 10 * time.Second, - KeepAlive: 60 * time.Second, - }).DialContext, - MaxIdleConns: 100, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - TLSClientConfig: &tls.Config{}, - }, - Timeout: 10 * time.Second, - } -} diff --git a/backend/pkg/server/api/handler.go b/backend/pkg/server/api/handler.go index 68aa4faf..c7980002 100644 --- a/backend/pkg/server/api/handler.go +++ b/backend/pkg/server/api/handler.go @@ -5,17 +5,22 @@ import ( "net/url" "github.com/gin-gonic/gin" - "github.com/kyverno/policy-reporter-ui/pkg/core/client" - core "github.com/kyverno/policy-reporter-ui/pkg/core/client" + "github.com/kyverno/policy-reporter-ui/pkg/api/core" + "github.com/kyverno/policy-reporter-ui/pkg/api/plugin" "github.com/kyverno/policy-reporter-ui/pkg/service" "github.com/kyverno/policy-reporter-ui/pkg/utils" "go.uber.org/zap" "golang.org/x/sync/errgroup" ) +type Endpoints struct { + Core *core.Client + Plugins map[string]*plugin.Client +} + type Handler struct { config *Config - clients map[string]*core.Client + clients map[string]*Endpoints boards map[string]CustomBoard service *service.Service } @@ -28,21 +33,6 @@ func (h *Handler) ListCustomBoards(ctx *gin.Context) { ctx.JSON(http.StatusOK, utils.ToList(h.boards)) } -func (h *Handler) GetPolicyDetails(ctx *gin.Context) { - details, err := h.service.PolicyDetails(ctx, ctx.Param("cluster"), ctx.Param("source"), ctx.Query("policies"), ctx.Request.URL.Query()) - if err != nil { - zap.L().Error( - "failed to generate policy sources", - zap.String("cluster", ctx.Param("cluster")), - zap.Error(err), - ) - ctx.AbortWithStatus(http.StatusInternalServerError) - return - } - - ctx.JSON(http.StatusOK, details) -} - func (h *Handler) ListPolicySources(ctx *gin.Context) { details, err := h.service.PolicySources(ctx, ctx.Param("cluster"), ctx.Request.URL.Query()) if err != nil { @@ -81,7 +71,7 @@ func (h *Handler) GetCustomBoard(ctx *gin.Context) { return } - client, ok := h.clients[ctx.Param("cluster")] + endpoints, ok := h.clients[ctx.Param("cluster")] if !ok { ctx.AbortWithStatus(http.StatusNotFound) return @@ -98,7 +88,7 @@ func (h *Handler) GetCustomBoard(ctx *gin.Context) { if len(sources) == 0 { g.Go(func() error { var err error - sources, err = client.ListSources(ctx, url.Values{}) + sources, err = endpoints.Core.ListSources(ctx, url.Values{}) return err }) @@ -106,7 +96,7 @@ func (h *Handler) GetCustomBoard(ctx *gin.Context) { var namespaces []string if len(config.Namespaces.Selector) > 0 { - ns, err := client.ResolveNamespaceSelector(ctx, config.Namespaces.Selector) + ns, err := endpoints.Core.ResolveNamespaceSelector(ctx, config.Namespaces.Selector) if err != nil { ctx.AbortWithError(http.StatusInternalServerError, err) return @@ -134,13 +124,13 @@ func (h *Handler) GetCustomBoard(ctx *gin.Context) { } func (h *Handler) Layout(ctx *gin.Context) { - client, ok := h.clients[ctx.Param("cluster")] + endpoints, ok := h.clients[ctx.Param("cluster")] if !ok { ctx.AbortWithStatus(http.StatusNotFound) return } - sources, err := client.ListSourceCategoryTree(ctx, ctx.Request.URL.Query()) + sources, err := endpoints.Core.ListSourceCategoryTree(ctx, ctx.Request.URL.Query()) if err != nil { zap.L().Error("failed to call core API", zap.Error(err)) ctx.AbortWithStatus(http.StatusInternalServerError) @@ -151,13 +141,14 @@ func (h *Handler) Layout(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{ "sources": MapSourceCategoryTreeToNavi(sources), + "policies": MapSourcesToPolicyNavi(sources), "customBoards": MapCustomBoardsToNavi(h.boards), "profile": profile, }) } func (h *Handler) Dashboard(ctx *gin.Context) { - client, ok := h.clients[ctx.Param("cluster")] + endpoints, ok := h.clients[ctx.Param("cluster")] if !ok { ctx.AbortWithStatus(http.StatusNotFound) return @@ -169,7 +160,7 @@ func (h *Handler) Dashboard(ctx *gin.Context) { if len(sources) == 0 { g.Go(func() error { var err error - sources, err = client.ListSources(ctx, url.Values{}) + sources, err = endpoints.Core.ListSources(ctx, url.Values{}) return err }) @@ -180,7 +171,7 @@ func (h *Handler) Dashboard(ctx *gin.Context) { var namespaces []string g.Go(func() error { var err error - namespaces, err = client.ListNamespaces(ctx, url.Values{ + namespaces, err = endpoints.Core.ListNamespaces(ctx, url.Values{ "sources": query["sources"], "kinds": query["kinds"], "categories": query["categories"], @@ -206,6 +197,61 @@ func (h *Handler) Dashboard(ctx *gin.Context) { ctx.JSON(http.StatusOK, dashboard) } -func NewHandler(config *Config, clients map[string]*client.Client, customBoards map[string]CustomBoard) *Handler { - return &Handler{config, clients, customBoards, service.New(clients)} +func (h *Handler) Policies(ctx *gin.Context) { + endpoints, ok := h.clients[ctx.Param("cluster")] + if !ok { + ctx.AbortWithStatus(http.StatusNotFound) + return + } + + source := ctx.Param("source") + + query := ctx.Request.URL.Query() + query.Set("sources", source) + + list, err := endpoints.Core.ListPolicies(ctx, query) + if err != nil { + zap.L().Error("failed to load policies from core api", zap.String("cluster", ctx.Param("cluster")), zap.Error(err)) + ctx.AbortWithStatus(http.StatusInternalServerError) + return + } + + if plugin, ok := endpoints.Plugins[source]; ok { + policies, err := plugin.ListPolicies(ctx, query) + if err != nil { + zap.L().Error("failed to load policies from plugin", zap.String("cluster", ctx.Param("cluster")), zap.String("plugin", source), zap.Error(err)) + } else { + ctx.JSON(http.StatusOK, MapPluginPolicies(policies, list)) + return + } + } + + ctx.JSON(http.StatusOK, MapPoliciesFromCore(list)) +} + +func (h *Handler) GetPolicyDetails(ctx *gin.Context) { + details, err := h.service.PolicyDetails(ctx, ctx.Param("cluster"), ctx.Param("source"), ctx.Query("policies"), ctx.Request.URL.Query()) + if err != nil { + zap.L().Error( + "failed to generate policy sources", + zap.String("cluster", ctx.Param("cluster")), + zap.Error(err), + ) + ctx.AbortWithStatus(http.StatusInternalServerError) + return + } + + ctx.JSON(http.StatusOK, details) +} + +func NewHandler(config *Config, apis map[string]*Endpoints, customBoards map[string]CustomBoard) *Handler { + endpoints := make(map[string]*service.Endpoints, len(apis)) + for cluster, value := range apis { + endpoints[cluster] = &service.Endpoints{ + Core: value.Core, + Plugins: value.Plugins, + } + } + + return &Handler{config, apis, customBoards, service.New(endpoints)} } diff --git a/backend/pkg/server/api/mapper.go b/backend/pkg/server/api/mapper.go index 3d7c88a6..c8286df0 100644 --- a/backend/pkg/server/api/mapper.go +++ b/backend/pkg/server/api/mapper.go @@ -4,11 +4,12 @@ import ( "fmt" "sort" - "github.com/kyverno/policy-reporter-ui/pkg/core/client" + "github.com/kyverno/policy-reporter-ui/pkg/api/core" + "github.com/kyverno/policy-reporter-ui/pkg/api/plugin" "github.com/kyverno/policy-reporter-ui/pkg/utils" ) -func MapSourceCategoryTreeToNavi(sources []client.SourceCategoryTree) []NavigationItem { +func MapSourceCategoryTreeToNavi(sources []core.SourceCategoryTree) []NavigationItem { sourceBoards := make([]NavigationItem, 0) if len(sources) == 1 { for _, category := range sources[0].Categories { @@ -60,6 +61,23 @@ func MapSourceCategoryTreeToNavi(sources []client.SourceCategoryTree) []Navigati return sourceBoards } +func MapSourcesToPolicyNavi(sources []core.SourceCategoryTree) []NavigationItem { + sourceBoards := make([]NavigationItem, 0) + for _, source := range sources { + + sourceBoards = append(sourceBoards, NavigationItem{ + Title: utils.Title(source.Name), + Path: fmt.Sprintf("/policies/%s", source.Name), + }) + } + + sort.SliceStable(sourceBoards, func(a, b int) bool { + return sourceBoards[a].Title < sourceBoards[b].Title + }) + + return sourceBoards +} + func MapCustomBoardsToNavi(boards map[string]CustomBoard) []NavigationItem { customBoards := make([]NavigationItem, 0, len(boards)) for _, board := range boards { @@ -75,3 +93,81 @@ func MapCustomBoardsToNavi(boards map[string]CustomBoard) []NavigationItem { return customBoards } + +func MapPoliciesFromCore(policies []core.Policy) map[string][]Policy { + results := make(map[string][]Policy) + for _, policy := range policies { + category := policy.Category + if category == "" { + category = "Other" + } + + if _, ok := results[category]; !ok { + results[category] = make([]Policy, 0) + } + + results[category] = append(results[category], Policy{ + Name: policy.Name, + Category: policy.Category, + Severity: policy.Severity, + Source: policy.Source, + Title: policy.Name, + Results: policy.Results, + }) + } + + return results +} + +func MapPluginPolicies(policies []plugin.Policy, coreList []core.Policy) map[string][]Policy { + results := make(map[string][]Policy) + + if coreList == nil || len(coreList) == 0 { + return results + } + + cache := make(map[string]map[string]*core.Policy, len(coreList)) + for _, p := range coreList { + p := p + if _, ok := cache[p.Category]; !ok { + cache[p.Category] = make(map[string]*core.Policy) + } + cache[p.Category][p.Name] = &p + } + + for _, policy := range policies { + if _, ok := cache[policy.Category]; !ok { + continue + } + + corePolicy := cache[policy.Category][policy.ID()] + if corePolicy == nil { + corePolicy = cache[policy.Category][policy.Name] + } + if corePolicy == nil { + continue + } + + category := policy.Category + if category == "" { + category = "Other" + } + + if _, ok := results[category]; !ok { + results[category] = make([]Policy, 0) + } + + results[category] = append(results[category], Policy{ + Namespace: policy.Namespace, + Name: corePolicy.Name, + Category: category, + Severity: policy.Severity, + Description: policy.Description, + Source: corePolicy.Source, + Title: policy.Title, + Results: corePolicy.Results, + }) + } + + return results +} diff --git a/backend/pkg/server/api/model.go b/backend/pkg/server/api/model.go index c1ad4bd9..3c6f4225 100644 --- a/backend/pkg/server/api/model.go +++ b/backend/pkg/server/api/model.go @@ -1,5 +1,16 @@ package api +type Policy struct { + Source string `json:"source,omitempty"` + Category string `json:"category,omitempty"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + Severity string `json:"severity,omitempty"` + Results map[string]int `json:"results"` +} + type DefaultFilter struct { Resources []string `json:"resources"` ClusterResources []string `json:"clusterResources"` diff --git a/backend/pkg/server/server.go b/backend/pkg/server/server.go index 6ec86a5c..721b1395 100644 --- a/backend/pkg/server/server.go +++ b/backend/pkg/server/server.go @@ -9,7 +9,8 @@ import ( "github.com/gosimple/slug" "go.uber.org/zap" - "github.com/kyverno/policy-reporter-ui/pkg/core/client" + "github.com/kyverno/policy-reporter-ui/pkg/api/core" + "github.com/kyverno/policy-reporter-ui/pkg/api/plugin" "github.com/kyverno/policy-reporter-ui/pkg/server/api" ) @@ -19,7 +20,7 @@ type APIHandler interface { type Server struct { middelware []gin.HandlerFunc - clients map[string]*client.Client + apis map[string]*api.Endpoints engine *gin.Engine api *gin.RouterGroup proxies *gin.RouterGroup @@ -40,26 +41,24 @@ func (s *Server) RegisterUI(path string) { s.engine.NoRoute(handler...) } -func (s *Server) RegisterCluster(name string, client *client.Client, proxies map[string]*httputil.ReverseProxy) { +func (s *Server) RegisterCluster(name string, client *core.Client, plugins map[string]*plugin.Client, proxy *httputil.ReverseProxy) { id := slug.Make(name) - s.clients[id] = client + s.apis[id] = &api.Endpoints{Core: client, Plugins: plugins} group := s.proxies.Group(id) - for p, rp := range proxies { - group.Group(p).Any("/*proxy", func(ctx *gin.Context) { - req := ctx.Request.Clone(ctx) - req.URL.Path = ctx.Param("proxy") + group.Group("core").Any("/*proxy", func(ctx *gin.Context) { + req := ctx.Request.Clone(ctx) + req.URL.Path = ctx.Param("proxy") - rp.ServeHTTP(ctx.Writer, req) - }) - } + proxy.ServeHTTP(ctx.Writer, req) + }) zap.L().Debug("cluster registered", zap.String("name", name), zap.String("id", id)) } func (s *Server) RegisterAPI(c *api.Config, configs map[string]api.CustomBoard) { - handler := api.NewHandler(c, s.clients, configs) + handler := api.NewHandler(c, s.apis, configs) s.api.GET("config", handler.Config) s.api.GET("custom-board/list", handler.ListCustomBoards) @@ -67,6 +66,7 @@ func (s *Server) RegisterAPI(c *api.Config, configs map[string]api.CustomBoard) s.api.GET("config/:cluster/resource/:id", handler.GetResourceDetails) s.api.GET("config/:cluster/policy-sources", handler.ListPolicySources) s.api.GET("config/:cluster/:source/policy/details", handler.GetPolicyDetails) + s.api.GET("config/:cluster/:source/policies", handler.Policies) s.api.GET("config/:cluster/layout", handler.Layout) s.api.GET("config/:cluster/dashboard", handler.Dashboard) @@ -75,7 +75,7 @@ func (s *Server) RegisterAPI(c *api.Config, configs map[string]api.CustomBoard) func NewServer(engine *gin.Engine, port int, middleware []gin.HandlerFunc) *Server { return &Server{ middelware: middleware, - clients: make(map[string]*client.Client), + apis: make(map[string]*api.Endpoints), engine: engine, api: engine.Group("/api", middleware...), proxies: engine.Group("/proxy", middleware...), diff --git a/backend/pkg/service/mapper.go b/backend/pkg/service/mapper.go index 5eeaea58..398040da 100644 --- a/backend/pkg/service/mapper.go +++ b/backend/pkg/service/mapper.go @@ -4,11 +4,11 @@ import ( "fmt" "sort" - "github.com/kyverno/policy-reporter-ui/pkg/core/client" + "github.com/kyverno/policy-reporter-ui/pkg/api/core" "github.com/kyverno/policy-reporter-ui/pkg/utils" ) -func MapFindingSourcesToSourceItem(findings *client.Findings) []SourceItem { +func MapFindingSourcesToSourceItem(findings *core.Findings) []SourceItem { findingSources := make(map[string]bool, 0) for _, f := range findings.Counts { findingSources[f.Source] = true @@ -29,7 +29,7 @@ func MapFindingSourcesToSourceItem(findings *client.Findings) []SourceItem { return sourceItems } -func MapFindingSourcesToFindingCharts(findings *client.Findings) map[string]*Chart { +func MapFindingSourcesToFindingCharts(findings *core.Findings) map[string]*Chart { charts := make(map[string]*Chart, 0) totals := make(map[string]int, 0) @@ -68,7 +68,7 @@ func MapFindingSourcesToFindingCharts(findings *client.Findings) map[string]*Cha return charts } -func MapFindingsToSourceStatusChart(title string, findings *client.Findings) *Chart { +func MapFindingsToSourceStatusChart(title string, findings *core.Findings) *Chart { if len(findings.Counts) == 0 { return &Chart{ Name: title, @@ -106,7 +106,7 @@ func MapFindingsToSourceStatusChart(title string, findings *client.Findings) *Ch } } -func MapNamespaceStatusCountsToChart(title string, namespaces client.NamespaceStatusCounts) *Chart { +func MapNamespaceStatusCountsToChart(title string, namespaces core.NamespaceStatusCounts) *Chart { sets := map[string]*Dataset{ StatusPass: {Label: utils.Title(StatusPass), Data: make([]int, 0)}, StatusFail: {Label: utils.Title(StatusFail), Data: make([]int, 0)}, @@ -156,7 +156,7 @@ func MapNamespaceStatusCountsToChart(title string, namespaces client.NamespaceSt } } -func MapNamespaceStatusCountsToCharts(findings map[string]client.NamespaceStatusCounts) map[string]*Chart { +func MapNamespaceStatusCountsToCharts(findings map[string]core.NamespaceStatusCounts) map[string]*Chart { charts := make(map[string]*Chart, len(findings)) for source, namespaces := range findings { @@ -166,7 +166,7 @@ func MapNamespaceStatusCountsToCharts(findings map[string]client.NamespaceStatus return charts } -func SumResourceCounts(results []client.ResourceStatusCount) map[string]int { +func SumResourceCounts(results []core.ResourceStatusCount) map[string]int { values := map[string]int{ StatusPass: 0, StatusFail: 0, @@ -186,7 +186,7 @@ func SumResourceCounts(results []client.ResourceStatusCount) map[string]int { return values } -func MapResourceSourceChart(results []client.ResourceStatusCount) *Chart { +func MapResourceSourceChart(results []core.ResourceStatusCount) *Chart { sets := map[string]*Dataset{ StatusPass: {Label: utils.Title(StatusPass), Data: make([]int, 0)}, StatusFail: {Label: utils.Title(StatusFail), Data: make([]int, 0)}, @@ -235,7 +235,7 @@ func MapResourceSourceChart(results []client.ResourceStatusCount) *Chart { } } -func MapCategoriesToChart(title string, categories []client.Category) *Chart { +func MapCategoriesToChart(title string, categories []core.Category) *Chart { sets := map[string]*Dataset{ StatusPass: {Label: utils.Title(StatusPass), Data: make([]int, 0)}, StatusFail: {Label: utils.Title(StatusFail), Data: make([]int, 0)}, diff --git a/backend/pkg/service/model.go b/backend/pkg/service/model.go index 2fd763c2..584ce5ab 100644 --- a/backend/pkg/service/model.go +++ b/backend/pkg/service/model.go @@ -1,6 +1,6 @@ package service -import core "github.com/kyverno/policy-reporter-ui/pkg/core/client" +import "github.com/kyverno/policy-reporter-ui/pkg/api/core" const ( StatusPass = "pass" @@ -71,9 +71,39 @@ type PolicyCharts struct { ClusterScope map[string]int `json:"clusterScope"` } +type Engine struct { + Name string `json:"name"` + KubernetesVersion string `json:"kubernetesVersion,omitempty"` + Version string `json:"version,omitempty"` + Subjects []string `json:"subjects,omitempty"` +} + +type SourceCode struct { + ContentType string `json:"contentType"` + Content string `json:"content"` +} + +type Item struct { + Title string `json:"title"` + Value string `json:"value"` +} + +type Details struct { + Title string `json:"title"` + Items []Item `json:"items"` +} + type PolicyDetails struct { - Title string `json:"title"` - Name string `json:"name"` - Namespaces []string `json:"namespaces"` - Chart PolicyCharts `json:"charts"` + Title string `json:"title"` + Name string `json:"name"` + Namespaces []string `json:"namespaces"` + Chart PolicyCharts `json:"charts"` + Description string `json:"description"` + Severity string `json:"severity,omitempty"` + Engine *Engine `json:"engine,omitempty"` + SourceCode *SourceCode `json:"sourceCode,omitempty"` + Additional []Item `json:"additional,omitempty"` + Details []Details `json:"details,omitempty"` + References []string `json:"references,omitempty"` + ShowDetails bool `json:"showDetails"` } diff --git a/backend/pkg/service/service.go b/backend/pkg/service/service.go index 0c1808d3..482a3896 100644 --- a/backend/pkg/service/service.go +++ b/backend/pkg/service/service.go @@ -8,8 +8,10 @@ import ( "strconv" "sync" - core "github.com/kyverno/policy-reporter-ui/pkg/core/client" + "github.com/kyverno/policy-reporter-ui/pkg/api/core" + "github.com/kyverno/policy-reporter-ui/pkg/api/plugin" "github.com/kyverno/policy-reporter-ui/pkg/utils" + "go.uber.org/zap" "golang.org/x/sync/errgroup" ) @@ -17,14 +19,39 @@ var ( ErrNoClient = errors.New("client for cluster not found") ) +type Endpoints struct { + Core *core.Client + Plugins map[string]*plugin.Client +} + type Service struct { - clients map[string]*core.Client + endpoints map[string]*Endpoints } -func (s *Service) PolicyDetails(ctx context.Context, cluster, source, policy string, query url.Values) (any, error) { - client, ok := s.clients[cluster] +func (s *Service) core(cluster string) (*core.Client, error) { + endpoints, ok := s.endpoints[cluster] + if !ok { + return nil, ErrNoClient + } + + return endpoints.Core, nil +} + +func (s *Service) plugin(cluster, p string) (*plugin.Client, bool) { + endpoints, ok := s.endpoints[cluster] if !ok { - return nil, errors.New("cluster not found") + return nil, false + } + + c, ok := endpoints.Plugins[p] + + return c, ok +} + +func (s *Service) PolicyDetails(ctx context.Context, cluster, source, policy string, query url.Values) (any, error) { + client, err := s.core(cluster) + if err != nil { + return nil, err } query["sources"] = []string{source} @@ -32,6 +59,21 @@ func (s *Service) PolicyDetails(ctx context.Context, cluster, source, policy str g := &errgroup.Group{} + var details *plugin.PolicyDetails + if plugin, ok := s.plugin(cluster, source); ok { + g.Go(func() error { + details, err = plugin.GetPolicy(ctx, policy) + zap.L().Error( + "failed to load policy details from plugin", + zap.String("cluster", cluster), + zap.String("source", source), + zap.Error(err), + ) + + return nil + }) + } + var namespaces []string g.Go(func() error { var err error @@ -65,22 +107,61 @@ func (s *Service) PolicyDetails(ctx context.Context, cluster, source, policy str return nil, err } - return &PolicyDetails{ + title := utils.Title(policy) + if details != nil { + title = details.Title + } + + response := &PolicyDetails{ Namespaces: namespaces, - Title: utils.Title(policy), + Title: title, Name: policy, Chart: PolicyCharts{ - Findings: MapFindingsToSourceStatusChart(utils.Title(policy), findings), - NamespaceScope: MapNamespaceStatusCountsToChart(utils.Title(policy), result), + Findings: MapFindingsToSourceStatusChart(title, findings), + NamespaceScope: MapNamespaceStatusCountsToChart(title, result), ClusterScope: clusterResult, }, - }, nil + } + + if details != nil { + response.Title = details.Title + response.Description = details.Description + response.Severity = details.Severity + response.References = details.References + response.Additional = utils.Map(details.Additional, func(i plugin.Item) Item { + return Item{Title: i.Title, Value: i.Value} + }) + response.ShowDetails = true + + response.Engine = &Engine{ + Name: details.Engine.Name, + Version: details.Engine.Version, + KubernetesVersion: details.Engine.KubernetesVersion, + Subjects: details.Engine.Subjects, + } + + response.SourceCode = &SourceCode{ + ContentType: details.SourceCode.ContentType, + Content: details.SourceCode.Content, + } + + response.Details = utils.Map(details.Details, func(d plugin.Details) Details { + return Details{ + Title: d.Title, + Items: utils.Map(d.Items, func(i plugin.Item) Item { + return Item{Title: i.Title, Value: i.Value} + }), + } + }) + } + + return response, nil } func (s *Service) PolicySources(ctx context.Context, cluster string, query url.Values) ([]Source, error) { - client, ok := s.clients[cluster] - if !ok { - return nil, errors.New("cluster not found") + client, err := s.core(cluster) + if err != nil { + return nil, err } tree, err := client.ListSourceCategoryTree(ctx, query) @@ -113,9 +194,9 @@ func (s *Service) PolicySources(ctx context.Context, cluster string, query url.V } func (s *Service) ResourceDetails(ctx context.Context, cluster string, id string, query url.Values) (*ResourceDetails, error) { - client, ok := s.clients[cluster] - if !ok { - return nil, errors.New("cluster not found") + client, err := s.core(cluster) + if err != nil { + return nil, err } query.Set("resource_id", id) @@ -184,9 +265,9 @@ func (s *Service) ResourceDetails(ctx context.Context, cluster string, id string } func (s *Service) Dashboard(ctx context.Context, cluster string, sources []string, namespaces []string, clusterScope bool, query url.Values) (*Dashboard, error) { - client, ok := s.clients[cluster] - if !ok { - return nil, errors.New("cluster not found") + client, err := s.core(cluster) + if err != nil { + return nil, err } g := &errgroup.Group{} @@ -306,6 +387,6 @@ func BuildFilters(baseFilter url.Values) (url.Values, url.Values, url.Values) { return combinedFilter, namespaceFilter, clusterFilter } -func New(clients map[string]*core.Client) *Service { +func New(clients map[string]*Endpoints) *Service { return &Service{clients} } diff --git a/frontend/app.vue b/frontend/app.vue index 1181c5cc..90dd29b8 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -17,11 +17,13 @@ import { cluster } from "~/modules/core/api"; overflow: hidden!important; } - .v-theme--dark .v-data-table-footer { + .v-theme--dark .v-data-table-footer, + .v-theme--dark .top-border { border-top: 1px solid rgba(255, 255, 255, 0.12) } - .v-theme--light .v-data-table-footer { + .v-theme--light .v-data-table-footer, + .v-theme--light .top-border { border-top: 1px solid #E1DCDF; } diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 52a8f3183500476a0a9cdf4a8ce101c4c4fb50d0..4e4d076e6c6f16a10f8ee7c14f5c049ef2bafe45 100755 GIT binary patch delta 72075 zcmeF430zgx`uEQ{a+Iy&tbmGRDyTT?0TGU~qT-MfsFVl@M;Vks!J>nesg7o` zHehOMmPw{Lq+&)+X`oS2sZfrkiIw^OetRvHy4`N~{;PZ6&!?>q&w9RVe%9J+ue~{k zUGz?!;;nU;bZDEm!)`oSwsSfOx&~DIGm5xqGailn+lW!s&d0%km zpOKiH5}BNAT8%oY!cK@tNOVM*Ozw!U4ttu_WNHNcAoA6NPJ*t5q?uDI%L3lko0~iO zEU98LH9&NZtI1RYddBn^-zZ0tX{sY3$~Sd}X^tkFnW(3mJ9>VPQ~;UV&?`1RGBzS9 zrNgx3kM-SVcjs8t%ciYGY)$BFXg1|3Xy%GYb0j7sJPQ@Eu6X<)uZ_g)LcAkB+Ba&7 z$pV}CPv8gBMI@&uL{4|4gu_4=G8d?F6H?Ps5H$J0Z>UEfVe#>)R#(8DY}jH zM`Oh=!KPnp!=~S+Bt=9GBGIu{whRnHvfpe z+NG&;ep@*iDuc56lXppdy`7qH(Md7UT}-BPu<7-w5y>etQA$#DYO-T$x@l8;lL_-F zbEdB>X9qO9`H@OrevaxLWUb}Hz8Cc%pEENlF)j`#k;&Fs&Z9=q9G0ItN&5^mhj$+| z9Gm$vG`&0znzME)G>3JN(mpD_s4VgS$doknH90zdR&q%9}kkwnG4OaN=%4O!3k`_S?q}Potlse&a;0;L`tmh^yqYlvL{2c z#L>_ks$giI6G6}{XLhV3k~KS$qtl|H2lbIoZ`fC+SLL-xk94G@q$5GkUUJezC8n_N zCJVaBQCp`+RB?Cu43O1DCr?Rr#6_7>2T4Cnfo7`V(46SK6!%tkeHC9x@k;|`xLy-HsinVC+qpyqUc0K`KtKOR>O

TXf|jcxEH2> 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 diff --git a/frontend/components/CollapseBtn.vue b/frontend/components/CollapseBtn.vue index ab36fd9f..7eaeeaa0 100644 --- a/frontend/components/CollapseBtn.vue +++ b/frontend/components/CollapseBtn.vue @@ -1,7 +1,7 @@ diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index c757a4cd..031e99e8 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -13,8 +13,26 @@ + + + + + + {{ child.title }} + + + diff --git a/frontend/modules/core/components/Results.vue b/frontend/modules/core/components/Results.vue index cf281863..7fe4022c 100644 --- a/frontend/modules/core/components/Results.vue +++ b/frontend/modules/core/components/Results.vue @@ -32,7 +32,7 @@

- + {{ item.message }} @@ -45,7 +45,7 @@
-
+
{{ item.message }}
diff --git a/frontend/modules/core/components/policy/Details.vue b/frontend/modules/core/components/policy/Details.vue new file mode 100644 index 00000000..bf7e2672 --- /dev/null +++ b/frontend/modules/core/components/policy/Details.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/frontend/modules/core/components/policy/Item.vue b/frontend/modules/core/components/policy/Item.vue index 6fc73cb2..e9175886 100644 --- a/frontend/modules/core/components/policy/Item.vue +++ b/frontend/modules/core/components/policy/Item.vue @@ -1,12 +1,12 @@ diff --git a/frontend/modules/core/components/policy/SourceGroup.vue b/frontend/modules/core/components/policy/SourceGroup.vue index 70ea57a1..11dba5c7 100644 --- a/frontend/modules/core/components/policy/SourceGroup.vue +++ b/frontend/modules/core/components/policy/SourceGroup.vue @@ -4,29 +4,49 @@ {{ source.title }}
- + - + - +
- diff --git a/frontend/modules/core/components/policy/StatusCharts.vue b/frontend/modules/core/components/policy/StatusCharts.vue index 1cfa919f..324d716a 100644 --- a/frontend/modules/core/components/policy/StatusCharts.vue +++ b/frontend/modules/core/components/policy/StatusCharts.vue @@ -17,7 +17,7 @@
-
+
{{ item.message }}
diff --git a/frontend/modules/core/types.ts b/frontend/modules/core/types.ts index d96a1125..0fb2a30e 100644 --- a/frontend/modules/core/types.ts +++ b/frontend/modules/core/types.ts @@ -66,8 +66,11 @@ export type Category = { } export type PolicyResult = { - policy: string; + name: string; + namespace?: string; + title: string; source: string; + description: string; category: string; severity?: Severity; results: { @@ -120,8 +123,9 @@ export type Navigation = { export type LayoutConfig = { profile?: Profile; - sources: Navigation[] - customBoards: Navigation[] + sources: Navigation[]; + policies: Navigation[]; + customBoards: Navigation[]; } export type Dataset = { @@ -187,11 +191,26 @@ export type PolicyDetails = { title: string; name: string; namespaces: string[]; + references?: string[]; + description: string; + showDetails: boolean; + engine?: { + name: string; + kubernetesVersion: string; + version: string; + subjects: string[]; + }; + sourceCode?: { + contentType: string; + content: string; + }; charts: { findings: Chart; namespaceScope: Chart; clusterScope: { [key in Status]: number; }; }; + additional: { title: string; value: string }[] + details: { title: string; items: { title?: string; value: string }[] }[]; } export type Config = { diff --git a/frontend/package.json b/frontend/package.json index ea6a4b69..9247280c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,11 +21,13 @@ "vue-router": "^4.2.5" }, "dependencies": { + "@highlightjs/vue-plugin": "^2.1.0", "@pinia/nuxt": "^0.5.1", "@vueuse/nuxt": "^10.6.1", "axios": "^1.6.2", "chart.js": "^4.4.0", "chroma-js": "^2.4.2", + "highlight.js": "^11.9.0", "lodash.debounce": "^4.0.8", "pinia": "^2.1.7", "vue-chartjs": "^5.2.0", diff --git a/frontend/pages/policies/[source]/[policy].vue b/frontend/pages/policies/[source]/[policy].vue index 55e1ecf2..feac237b 100644 --- a/frontend/pages/policies/[source]/[policy].vue +++ b/frontend/pages/policies/[source]/[policy].vue @@ -1,19 +1,20 @@