Skip to content

Commit

Permalink
Added ListEvaluationHistory RPC implementation. (#3784)
Browse files Browse the repository at this point in the history
ListEvaluationHistory RPC returns a paginated list of evaluation
events based on the given cursor and filter.

Routines for managing a filter are implemented as well, in a way that
should be reusable within other endpoints supporting filters. This
implementation only allows and-joined predicates. Such predicates
allow only or-joined equality/inequality checks. Simply put, if a
filter entry starts with the exclamation mark, it is added to the
inequality check, while it is added to the equality check
otherwise. Finally, timerange based filtering is supported.

Routines for managing a cursor are implemented as well, but are not
intended to be generic. Cursors are tightly coupled with the
underlying extraction logic and are harder to refactor, and the
additional effort was not considered valuable at this time.

Fixes #3746
  • Loading branch information
blkt authored Jul 10, 2024
1 parent 3931da3 commit 87416ec
Show file tree
Hide file tree
Showing 17 changed files with 4,230 additions and 953 deletions.
15 changes: 15 additions & 0 deletions database/mock/store.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

72 changes: 72 additions & 0 deletions database/query/eval_history.sql
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,75 @@ INSERT INTO alert_events(
$3,
$4
);

-- name: ListEvaluationHistory :many
SELECT s.id::uuid AS evaluation_id,
s.most_recent_evaluation as evaluated_at,
-- entity type
CASE WHEN ere.repository_id IS NOT NULL THEN 'repository'::entities
WHEN ere.pull_request_id IS NOT NULL THEN 'pull_request'::entities
WHEN ere.artifact_id IS NOT NULL THEN 'artifact'::entities
END AS entity_type,
-- entity id
CASE WHEN ere.repository_id IS NOT NULL THEN r.id
WHEN ere.pull_request_id IS NOT NULL THEN pr.id
WHEN ere.artifact_id IS NOT NULL THEN a.id
END AS entity_id,
-- raw fields for entity names
r.repo_owner,
r.repo_name,
pr.pr_number,
a.artifact_name,
j.id as project_id,
-- rule type, name, and profile
rt.name AS rule_type,
ri.name AS rule_name,
p.name AS profile_name,
-- evaluation status and details
s.status AS evaluation_status,
s.details AS evaluation_details,
-- remediation status and details
re.status AS remediation_status,
re.details AS remediation_details,
-- alert status and details
ae.status AS alert_status,
ae.details AS alert_details
FROM evaluation_statuses s
JOIN evaluation_rule_entities ere ON ere.id = s.rule_entity_id
JOIN rule_instances ri ON ere.rule_id = ri.id
JOIN rule_type rt ON ri.rule_type_id = rt.id
JOIN profiles p ON ri.profile_id = p.id
LEFT JOIN repositories r ON ere.repository_id = r.id
LEFT JOIN pull_requests pr ON ere.pull_request_id = pr.id
LEFT JOIN artifacts a ON ere.artifact_id = a.id
LEFT JOIN remediation_events re ON re.evaluation_id = s.id
LEFT JOIN alert_events ae ON ae.evaluation_id = s.id
LEFT JOIN projects j ON r.project_id = j.id
WHERE (sqlc.narg(next)::timestamp without time zone IS NULL OR sqlc.narg(next) > s.most_recent_evaluation)
AND (sqlc.narg(prev)::timestamp without time zone IS NULL OR sqlc.narg(prev) < s.most_recent_evaluation)
-- inclusion filters
AND (sqlc.slice(entityTypes)::entities[] IS NULL OR entity_type::entities = ANY(sqlc.slice(entityTypes)::entities[]))
AND (sqlc.slice(entityNames)::text[] IS NULL OR ere.repository_id IS NULL OR r.repo_name = ANY(sqlc.slice(entityNames)::text[]))
AND (sqlc.slice(entityNames)::text[] IS NULL OR ere.pull_request_id IS NULL OR pr.pr_number::text = ANY(sqlc.slice(entityNames)::text[]))
AND (sqlc.slice(entityNames)::text[] IS NULL OR ere.artifact_id IS NULL OR a.artifact_name = ANY(sqlc.slice(entityNames)::text[]))
AND (sqlc.slice(profileNames)::text[] IS NULL OR p.name = ANY(sqlc.slice(profileNames)::text[]))
AND (sqlc.slice(remediations)::remediation_status_types[] IS NULL OR re.status = ANY(sqlc.slice(remediations)::remediation_status_types[]))
AND (sqlc.slice(alerts)::alert_status_types[] IS NULL OR ae.status = ANY(sqlc.slice(alerts)::alert_status_types[]))
AND (sqlc.slice(statuses)::eval_status_types[] IS NULL OR s.status = ANY(sqlc.slice(statuses)::eval_status_types[]))
-- exclusion filters
AND (sqlc.slice(notEntityTypes)::entities[] IS NULL OR entity_type::entities != ANY(sqlc.slice(notEntityTypes)::entities[]))
AND (sqlc.slice(notEntityNames)::text[] IS NULL OR ere.repository_id IS NULL OR r.repo_name != ANY(sqlc.slice(notEntityNames)::text[]))
AND (sqlc.slice(notEntityNames)::text[] IS NULL OR ere.pull_request_id IS NULL OR pr.pr_number::text != ANY(sqlc.slice(notEntityNames)::text[]))
AND (sqlc.slice(notEntityNames)::text[] IS NULL OR ere.artifact_id IS NULL OR a.artifact_name != ANY(sqlc.slice(notEntityNames)::text[]))
AND (sqlc.slice(notProfileNames)::text[] IS NULL OR p.name != ANY(sqlc.slice(notProfileNames)::text[]))
AND (sqlc.slice(notRemediations)::remediation_status_types[] IS NULL OR re.status != ANY(sqlc.slice(notRemediations)::remediation_status_types[]))
AND (sqlc.slice(notAlerts)::alert_status_types[] IS NULL OR ae.status != ANY(sqlc.slice(notAlerts)::alert_status_types[]))
AND (sqlc.slice(notStatuses)::eval_status_types[] IS NULL OR s.status != ANY(sqlc.slice(notStatuses)::eval_status_types[]))
-- time range filter
AND (sqlc.narg(fromts)::timestamp without time zone IS NULL
OR sqlc.narg(tots)::timestamp without time zone IS NULL
OR s.most_recent_evaluation BETWEEN sqlc.narg(fromts) AND sqlc.narg(tots))
-- implicit filter by project id
AND j.id = sqlc.arg(projectId)
ORDER BY s.most_recent_evaluation DESC
LIMIT sqlc.arg(size)::integer;
5 changes: 3 additions & 2 deletions docs/docs/ref/proto.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

233 changes: 215 additions & 18 deletions internal/controlplane/handlers_evalstatus.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ package controlplane

import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"

"github.com/google/uuid"
"github.com/rs/zerolog"
Expand All @@ -28,33 +31,184 @@ import (
"github.com/stacklok/minder/internal/db"
"github.com/stacklok/minder/internal/engine/engcontext"
"github.com/stacklok/minder/internal/flags"
"github.com/stacklok/minder/internal/history"
minderv1 "github.com/stacklok/minder/pkg/api/protobuf/go/minder/v1"
)

const (
defaultPageSize uint64 = 25
)

// ListEvaluationHistory lists current and past evaluation results for
// entities.
func (s *Server) ListEvaluationHistory(
ctx context.Context,
in *minderv1.ListEvaluationHistoryRequest,
) (*minderv1.ListEvaluationHistoryResponse, error) {
if flags.Bool(ctx, s.featureFlags, flags.EvalHistory) {
cursor := in.GetCursor()
zerolog.Ctx(ctx).Debug().
Strs("entity_type", in.GetEntityType()).
Strs("entity_name", in.GetEntityName()).
Strs("profile_name", in.GetProfileName()).
Strs("status", in.GetStatus()).
Strs("remediation", in.GetRemediation()).
Strs("alert", in.GetAlert()).
Str("from", in.GetFrom().String()).
Str("to", in.GetTo().String()).
Str("cursor.cursor", cursor.Cursor).
Uint64("cursor.size", cursor.Size).
Msg("ListEvaluationHistory request")
return &minderv1.ListEvaluationHistoryResponse{}, nil
}

return nil, status.Error(codes.Unimplemented, "Not implemented")
if !flags.Bool(ctx, s.featureFlags, flags.EvalHistory) {
return nil, status.Error(codes.Unimplemented, "Not implemented")
}

// process cursor
cursor := &history.DefaultCursor
size := defaultPageSize
if in.GetCursor() != nil {
parsedCursor, err := history.ParseListEvaluationCursor(
in.GetCursor().GetCursor(),
)
if err != nil {
return nil, status.Error(codes.InvalidArgument, "invalid cursor")
}
cursor = parsedCursor
size = in.GetCursor().GetSize()
}

// process filter
opts := []history.FilterOpt{}
opts = append(opts, FilterOptsFromStrings(in.GetEntityType(), history.WithEntityType)...)
opts = append(opts, FilterOptsFromStrings(in.GetEntityName(), history.WithEntityName)...)
opts = append(opts, FilterOptsFromStrings(in.GetProfileName(), history.WithProfileName)...)
opts = append(opts, FilterOptsFromStrings(in.GetStatus(), history.WithStatus)...)
opts = append(opts, FilterOptsFromStrings(in.GetRemediation(), history.WithRemediation)...)
opts = append(opts, FilterOptsFromStrings(in.GetAlert(), history.WithAlert)...)

if in.GetFrom() != nil {
opts = append(opts, history.WithFrom(in.GetFrom().AsTime()))
}
if in.GetTo() != nil {
opts = append(opts, history.WithTo(in.GetTo().AsTime()))
}

// we always filter by project id
opts = append(opts, history.WithProjectIDStr(in.GetContext().GetProject()))

filter, err := history.NewListEvaluationFilter(opts...)
if err != nil {
return nil, status.Error(codes.InvalidArgument, "invalid filter")
}

// retrieve data set
tx, err := s.store.BeginTransaction()
if err != nil {
return nil, status.Errorf(codes.Internal, "error starting transaction: %v", err)
}
defer s.store.Rollback(tx)

result, err := s.history.ListEvaluationHistory(
ctx,
s.store.GetQuerierWithTransaction(tx),
cursor,
size,
filter,
)
if err != nil {
return nil, status.Error(codes.Internal, "error retrieving evaluations")
}

// convert data set to proto
data, err := fromEvaluationHistoryRow(result.Data)
if err != nil {
return nil, err
}

// return data set to client
resp := &minderv1.ListEvaluationHistoryResponse{}
if len(data) == 0 {
return resp, nil
}

resp.Data = data
resp.Page = &minderv1.CursorPage{}

if result.Next != nil {
resp.Page.Next = makeCursor(result.Next, size)
}
if result.Prev != nil {
resp.Page.Prev = makeCursor(result.Prev, size)
}

return resp, nil
}

func fromEvaluationHistoryRow(
rows []db.ListEvaluationHistoryRow,
) ([]*minderv1.EvaluationHistory, error) {
res := []*minderv1.EvaluationHistory{}

for _, row := range rows {
var dbEntityType db.Entities
if err := dbEntityType.Scan(row.EntityType); err != nil {
return nil, errors.New("internal error")
}
entityType := dbEntityToEntity(dbEntityType)
entityName, err := getEntityName(dbEntityType, row)
if err != nil {
return nil, err
}

var alert *minderv1.EvaluationHistoryAlert
if row.AlertStatus.Valid {
alert = &minderv1.EvaluationHistoryAlert{
Status: string(row.AlertStatus.AlertStatusTypes),
Details: row.AlertDetails.String,
}
}
var remediation *minderv1.EvaluationHistoryRemediation
if row.RemediationStatus.Valid {
remediation = &minderv1.EvaluationHistoryRemediation{
Status: string(row.RemediationStatus.RemediationStatusTypes),
Details: row.RemediationDetails.String,
}
}

res = append(res, &minderv1.EvaluationHistory{
EvaluatedAt: timestamppb.New(row.EvaluatedAt),
Entity: &minderv1.EvaluationHistoryEntity{
Id: row.EvaluationID.String(),
Type: entityType,
Name: entityName,
},
Rule: &minderv1.EvaluationHistoryRule{
Name: row.RuleName,
RuleType: row.RuleType,
Profile: row.ProfileName,
},
Status: &minderv1.EvaluationHistoryStatus{
Status: string(row.EvaluationStatus),
Details: row.EvaluationDetails,
},
Alert: alert,
Remediation: remediation,
})
}

return res, nil
}

func makeCursor(cursor []byte, size uint64) *minderv1.Cursor {
return &minderv1.Cursor{
Cursor: base64.StdEncoding.EncodeToString(cursor),
Size: size,
}
}

// FilterOptsFromStrings calls the given function `f` on each element
// of values. Such elements are either "complex", i.e. they represent
// a comma-separated list of sub-elements, or "simple", they do not
// contain comma characters. If element contains one or more comma
// characters, it is further split into sub-elements before calling
// `f` in them.
func FilterOptsFromStrings(
values []string,
f func(string) history.FilterOpt,
) []history.FilterOpt {
opts := []history.FilterOpt{}
for _, val := range values {
for _, part := range strings.Split(val, ",") {
opts = append(opts, f(part))
}
}
return opts
}

// ListEvaluationResults lists the latest evaluation results for
Expand Down Expand Up @@ -437,3 +591,46 @@ func dbEntityToEntity(dbEnt db.Entities) minderv1.Entity {
return minderv1.Entity_ENTITY_UNSPECIFIED
}
}

func getEntityName(
dbEnt db.Entities,
row db.ListEvaluationHistoryRow,
) (string, error) {
switch dbEnt {
case db.EntitiesPullRequest:
if !row.RepoOwner.Valid {
return "", errors.New("repo_owner is missing")
}
if !row.RepoName.Valid {
return "", errors.New("repo_name is missing")
}
if !row.PrNumber.Valid {
return "", errors.New("pr_number is missing")
}
return fmt.Sprintf("%s/%s#%d",
row.RepoOwner.String,
row.RepoName.String,
row.PrNumber.Int64,
), nil
case db.EntitiesArtifact:
if !row.ArtifactName.Valid {
return "", errors.New("artifact_name is missing")
}
return row.ArtifactName.String, nil
case db.EntitiesRepository:
if !row.RepoOwner.Valid {
return "", errors.New("repo_owner is missing")
}
if !row.RepoName.Valid {
return "", errors.New("repo_name is missing")
}
return fmt.Sprintf("%s/%s",
row.RepoOwner.String,
row.RepoName.String,
), nil
case db.EntitiesBuildEnvironment:
return "", errors.New("invalid entity type")
default:
return "", errors.New("invalid entity type")
}
}
Loading

0 comments on commit 87416ec

Please sign in to comment.