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 52a8f318..4e4d076e 100755 Binary files a/frontend/bun.lockb and b/frontend/bun.lockb differ 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 }} + + + Custom Boards @@ -79,7 +97,6 @@ const bg = computed(() => { const navigation = [ { title: 'Dashboard', path: '/', exact: true }, - { title: 'Policies', path: '/policies', exact: true }, { title: 'Notification Targets', path: '/targets', exact: true }, ]; diff --git a/frontend/modules/core/api.ts b/frontend/modules/core/api.ts index 6485e938..226a4601 100644 --- a/frontend/modules/core/api.ts +++ b/frontend/modules/core/api.ts @@ -58,8 +58,12 @@ export class CoreAPI { return $fetch(`/api/config/${this.cluster}/policy-sources`, { baseURL: this.baseURL, params: applyExcludes(filter, [...this.nsExcludes, ...this.clusterExcludes]) }) } - policyDetails (source: string, policy: string, filter?: Filter) { - return $fetch(`/api/config/${this.cluster}/${source}/policy/details`, { baseURL: this.baseURL, params: applyExcludes({ ...filter, policies: [policy] }, [...this.nsExcludes, ...this.clusterExcludes]) }) + policyDetails (source: string, policy: string, namespace?: string) { + return $fetch(`/api/config/${this.cluster}/${source}/policy/details`, { baseURL: this.baseURL, params: applyExcludes({ policies: [policy], namespace }, [...this.nsExcludes, ...this.clusterExcludes]) }) + } + + policies (source: string, filter?: Filter) { + return $fetch<{ [category: string]: PolicyResult[] }>(`/api/config/${this.cluster}/${source}/policies`, { baseURL: this.baseURL, params: applyExcludes(filter, this.nsExcludes)}) } config () { @@ -134,10 +138,6 @@ export class CoreAPI { return $fetch('/proxy/'+this.cluster+'/core/v2/sources/categories', { baseURL: this.baseURL, params: { id, ...applyExcludes(filter, [...this.nsExcludes, ...this.clusterExcludes]) } }) } - policies (filter?: Filter) { - return $fetch('/proxy/'+this.cluster+'/core/v2/policies', { baseURL: this.baseURL, params: applyExcludes(filter, this.nsExcludes)}) - } - setPrefix (prefix: string): void { this.cluster = prefix cluster.value = prefix @@ -151,7 +151,7 @@ export class CoreAPI { export const create = (config: APIConfig): CoreAPI => new CoreAPI(config) -const applyExcludes = (filter: Filter | undefined, exclude: string[] | undefined) => { +const applyExcludes = (filter: T | undefined, exclude: string[] | undefined) => { if (!filter) return ({ exclude }) if (filter.kinds && filter.kinds.length > 0) return filter diff --git a/frontend/modules/core/components/PropertyCard.vue b/frontend/modules/core/components/PropertyCard.vue index 91472c66..4966ee2f 100644 --- a/frontend/modules/core/components/PropertyCard.vue +++ b/frontend/modules/core/components/PropertyCard.vue @@ -4,7 +4,7 @@ {{ value }} - + 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 @@ + + + + + Policy Details + + + + + + + + + + + + + Name + {{ policy.engine.name }} + + + Min. Version + {{ policy.engine.version }} + + + Subjects + {{ policy.engine.subjects.join(', ') }} + + + + + + + {{ item.title }} + {{ item.value }} + + + + + + + + + + + + {{ policy.description }} + + + + + + expand + + + + + + + + + + + + {{ item.title }} + {{ item.value }} + + + + + + + + + + + Source Code + + + + + + + + + + + + + + Close + + + + + + + + + + References + + + + + + + + + + + {{ link }} + + + + + + + + + + + + + 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 @@ - + - + - {{ item.policy }} + {{ item.title }} @@ -15,14 +15,23 @@ + + + + {{ item.description }} + + 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 @@ - + diff --git a/frontend/modules/core/components/resource/Results.vue b/frontend/modules/core/components/resource/Results.vue index a22c8a27..1160e0f1 100644 --- a/frontend/modules/core/components/resource/Results.vue +++ b/frontend/modules/core/components/resource/Results.vue @@ -22,6 +22,9 @@ v-model:items-per-page="options.itemsPerPage" v-model:page="options.page" > + + {{ value }} + @@ -33,7 +36,7 @@ - + {{ item.message }} @@ -46,7 +49,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 @@ - - - - - - - {{ route.params.source }}: {{ route.params.policy }} - - - back - - - - - + + + + + + {{ capilize(route.params.source) }}: {{ data.title }} + + + back + + + + + + + @@ -23,11 +24,12 @@ diff --git a/frontend/pages/policies/[source]/index.vue b/frontend/pages/policies/[source]/index.vue new file mode 100644 index 00000000..3b59be01 --- /dev/null +++ b/frontend/pages/policies/[source]/index.vue @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/frontend/pages/policies/[source]/info/[policy].vue b/frontend/pages/policies/[source]/info/[policy].vue new file mode 100644 index 00000000..b9bd5586 --- /dev/null +++ b/frontend/pages/policies/[source]/info/[policy].vue @@ -0,0 +1,41 @@ + + + + + + + {{ capilize(route.params.source) }}: {{ data.title }} + + + close + + + + + + + + + + + No additional Information available, ensure that you are running a required Plugin for the {{ route.params.source }} policies. + + + + + + + + diff --git a/frontend/plugins/04.highlight.client.ts b/frontend/plugins/04.highlight.client.ts new file mode 100644 index 00000000..e13cb0a7 --- /dev/null +++ b/frontend/plugins/04.highlight.client.ts @@ -0,0 +1,13 @@ +import hljs from 'highlight.js/lib/core' +import javascript from 'highlight.js/lib/languages/javascript' +import yaml from 'highlight.js/lib/languages/yaml' +import typescript from 'highlight.js/lib/languages/typescript' +import highlightJS from '@highlightjs/vue-plugin' +import 'highlight.js/styles/atom-one-dark.css' + +export default defineNuxtPlugin((nuxtApp) => { + hljs.registerLanguage('javascript', javascript) + hljs.registerLanguage('typescript', typescript) + hljs.registerLanguage('yaml', yaml) + nuxtApp.vueApp.use(highlightJS) +})