Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added ListEvaluationHistory RPC implementation. #3784

Merged
merged 13 commits into from
Jul 10, 2024
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,
blkt marked this conversation as resolved.
Show resolved Hide resolved
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may have already been discussed, but have you considered using a SQL generator, like https://github.com/Masterminds/squirrel? I'm worried this raw SQL will become unmaintainable and fragile, especially as we add these types of filters to more endpoints.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I started off thinking it was not possible to implement this without an SQL generator, and decided to prove that.
It turned out to be possible instead, so I haven't looked any further.

It is worth noting that the simple filtering logic we decided to implement only allows where conditions of the form

(X = val1 OR X = val2) AND (Y = val3 OR Y = val4)

These conditions are always definable in static SQL, with an additional column1 IS NOT NULL OR at the beginning. Filtering and pagination as discussed in design docs do not allow for more complex filters, and in such cases general search should be implemented instead.

I was not aware of the existence of squirrel, while I originally considered goqu, I'll have a look at both. Besides, the query is fairly efficient and using sqlc we have all the structs and bindings autogenerated, which I don't think is the case using squirrel.

I understand the query generator approach may still be preferable, I'll test squirrel on top of this branch to see what's the impact.

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
Loading