diff --git a/docs/ordering.md b/docs/ordering.md index 4b76c2bb..942b1bae 100644 --- a/docs/ordering.md +++ b/docs/ordering.md @@ -66,12 +66,13 @@ The `By` field is the database column name and is defined as a constant: ```go var DbColumnNameMap = map[DbColumnName]string{ - ComponentInstanceCcrn: "componentinstance_ccrn", - IssuePrimaryName: "issue_primary_name", - IssueMatchId: "issuematch_id", - IssueMatchRating: "issuematch_rating", - IssueMatchTargetRemediationDate: "issuematch_target_remediation_date", - SupportGroupName: "supportgroup_name", + ComponentInstanceCcrn: "componentinstance_ccrn", + IssuePrimaryName: "issue_primary_name", + IssueMatchId: "issuematch_id", + IssueMatchRating: "issuematch_rating", + IssueEarliestTargetRemediationDate: "agg_earliest_target_remediation_date", + IssueMatchTargetRemediationDate: "issuematch_target_remediation_date", + SupportGroupName: "supportgroup_name", } ``` diff --git a/internal/api/graphql/graph/baseResolver/vulnerability.go b/internal/api/graphql/graph/baseResolver/vulnerability.go index 02f1fabb..476b3918 100644 --- a/internal/api/graphql/graph/baseResolver/vulnerability.go +++ b/internal/api/graphql/graph/baseResolver/vulnerability.go @@ -18,7 +18,7 @@ import ( // It's designed for the Vulnerability List View in the UI // - Only returns Issues of type Vulnerability // - Only returns Issues with at least one IssueMatch with status "new" -// - Default ordering is by IssueVariantRating (descending) and IssuePrimaryName (ascending) +// - Default ordering is by IssueVariantRating (descending), EarliestTargetRemediationDate (descending) and IssuePrimaryName (ascending) func VulnerabilityBaseResolver(app app.Heureka, ctx context.Context, filter *model.VulnerabilityFilter, first *int, after *string, parent *model.NodeParent) (*model.VulnerabilityConnection, error) { requestedFields := GetPreloads(ctx) logrus.WithFields(logrus.Fields{ @@ -63,6 +63,10 @@ func VulnerabilityBaseResolver(app app.Heureka, ctx context.Context, filter *mod By: entity.IssueVariantRating, Direction: entity.OrderDirectionDesc, }) + opt.Order = append(opt.Order, entity.Order{ + By: entity.IssueEarliestTargetRemediationDate, + Direction: entity.OrderDirectionDesc, + }) opt.Order = append(opt.Order, entity.Order{ By: entity.IssuePrimaryName, Direction: entity.OrderDirectionAsc, diff --git a/internal/app/issue/issue_handler_test.go b/internal/app/issue/issue_handler_test.go index 94fb295a..28ba1783 100644 --- a/internal/app/issue/issue_handler_test.go +++ b/internal/app/issue/issue_handler_test.go @@ -7,6 +7,7 @@ import ( "math" "strconv" "testing" + "time" "github.com/cloudoperators/heureka/internal/app/common" "github.com/cloudoperators/heureka/internal/app/event" @@ -213,12 +214,12 @@ var _ = Describe("When listing Issues", Label("app", "ListIssues"), func() { filter.First = &pageSize issues := []entity.IssueResult{} for _, i := range test.NNewFakeIssueEntities(resElements) { - cursor, _ := mariadb.EncodeCursor(mariadb.WithIssue([]entity.Order{}, i, 0)) + cursor, _ := mariadb.EncodeCursor(mariadb.WithIssue([]entity.Order{}, i, 0, time.Time{})) issues = append(issues, entity.IssueResult{WithCursor: entity.WithCursor{Value: cursor}, Issue: lo.ToPtr(i)}) } cursors := lo.Map(issues, func(ir entity.IssueResult, _ int) string { - cursor, _ := mariadb.EncodeCursor(mariadb.WithIssue([]entity.Order{}, *ir.Issue, 0)) + cursor, _ := mariadb.EncodeCursor(mariadb.WithIssue([]entity.Order{}, *ir.Issue, 0, time.Time{})) return cursor }) @@ -226,7 +227,7 @@ var _ = Describe("When listing Issues", Label("app", "ListIssues"), func() { for len(cursors) < dbElements { i++ issue := test.NewFakeIssueEntity() - c, _ := mariadb.EncodeCursor(mariadb.WithIssue([]entity.Order{}, issue, 0)) + c, _ := mariadb.EncodeCursor(mariadb.WithIssue([]entity.Order{}, issue, 0, time.Time{})) cursors = append(cursors, c) } db.On("GetIssues", filter, []entity.Order{}).Return(issues, nil) diff --git a/internal/database/mariadb/cursor.go b/internal/database/mariadb/cursor.go index 99e3f834..56be16f4 100644 --- a/internal/database/mariadb/cursor.go +++ b/internal/database/mariadb/cursor.go @@ -8,6 +8,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "time" "github.com/cloudoperators/heureka/internal/entity" ) @@ -217,7 +218,7 @@ func WithComponentVersion(order []entity.Order, cv entity.ComponentVersion, isc } } -func WithIssue(order []entity.Order, issue entity.Issue, ivRating int64) NewCursor { +func WithIssue(order []entity.Order, issue entity.Issue, ivRating int64, earliestTargetRemediationDate time.Time) NewCursor { return func(cursors *cursors) error { order = GetDefaultOrder(order, entity.IssueId, entity.OrderDirectionAsc) for _, o := range order { @@ -228,6 +229,8 @@ func WithIssue(order []entity.Order, issue entity.Issue, ivRating int64) NewCurs cursors.fields = append(cursors.fields, Field{Name: entity.IssuePrimaryName, Value: issue.PrimaryName, Order: o.Direction}) case entity.IssueVariantRating: cursors.fields = append(cursors.fields, Field{Name: entity.IssueVariantRating, Value: ivRating, Order: o.Direction}) + case entity.IssueEarliestTargetRemediationDate: + cursors.fields = append(cursors.fields, Field{Name: entity.IssueEarliestTargetRemediationDate, Value: earliestTargetRemediationDate, Order: o.Direction}) default: continue } diff --git a/internal/database/mariadb/issue.go b/internal/database/mariadb/issue.go index 4a577fbe..765e1de6 100644 --- a/internal/database/mariadb/issue.go +++ b/internal/database/mariadb/issue.go @@ -6,6 +6,7 @@ package mariadb import ( "errors" "fmt" + "time" "github.com/go-sql-driver/mysql" "github.com/samber/lo" @@ -69,27 +70,39 @@ func ensureIssueFilter(filter *entity.IssueFilter) *entity.IssueFilter { } func getIssueJoins(filter *entity.IssueFilter, order []entity.Order) string { + return getIssueJoinsExtended(filter, order, false) +} + +func getIssueJoinsExtended(filter *entity.IssueFilter, order []entity.Order, includeAggJoins bool) string { joins := "" orderByRating := lo.ContainsBy(order, func(o entity.Order) bool { return o.By == entity.IssueVariantRating }) + orderByIssueMatch := lo.ContainsBy(order, func(o entity.Order) bool { + return o.By == entity.IssueEarliestTargetRemediationDate || + o.By == entity.IssueMatchId || + o.By == entity.IssueMatchRating || + o.By == entity.IssueMatchTargetRemediationDate + }) + if filter.AllServices || filter.HasIssueMatches { joins = fmt.Sprintf("%s\n%s", joins, ` RIGHT JOIN IssueMatch IM ON I.issue_id = IM.issuematch_issue_id `) } else if len(filter.IssueMatchStatus) > 0 || len(filter.ServiceId) > 0 || len(filter.ServiceCCRN) > 0 || - len(filter.IssueMatchId) > 0 || len(filter.SupportGroupCCRN) > 0 || len(filter.IssueMatchSeverity) > 0 { + len(filter.IssueMatchId) > 0 || len(filter.SupportGroupCCRN) > 0 || len(filter.IssueMatchSeverity) > 0 || + orderByIssueMatch || includeAggJoins { joins = fmt.Sprintf("%s\n%s", joins, ` LEFT JOIN IssueMatch IM ON I.issue_id = IM.issuematch_issue_id `) } - if len(filter.ServiceId) > 0 || len(filter.ServiceCCRN) > 0 || len(filter.SupportGroupCCRN) > 0 || filter.AllServices { + if len(filter.ServiceId) > 0 || len(filter.ServiceCCRN) > 0 || len(filter.SupportGroupCCRN) > 0 || filter.AllServices || includeAggJoins { joins = fmt.Sprintf("%s\n%s", joins, ` LEFT JOIN ComponentInstance CI ON CI.componentinstance_id = IM.issuematch_component_instance_id `) - if len(filter.ServiceCCRN) > 0 || filter.AllServices { + if len(filter.ServiceCCRN) > 0 || filter.AllServices || includeAggJoins { joins = fmt.Sprintf("%s\n%s", joins, ` LEFT JOIN ComponentVersion CV ON CI.componentinstance_component_version_id = CV.componentversion_id LEFT JOIN Service S ON S.service_id = CI.componentinstance_service_id @@ -103,7 +116,7 @@ func getIssueJoins(filter *entity.IssueFilter, order []entity.Order) string { } } - if len(filter.ComponentVersionId) > 0 || len(filter.ComponentId) > 0 { + if len(filter.ComponentVersionId) > 0 || len(filter.ComponentId) > 0 || includeAggJoins { joins = fmt.Sprintf("%s\n%s", joins, ` LEFT JOIN ComponentVersionIssue CVI ON I.issue_id = CVI.componentversionissue_issue_id `) @@ -141,6 +154,8 @@ func getIssueColumns(order []entity.Order) string { switch o.By { case entity.IssueVariantRating: columns = fmt.Sprintf("%s, MAX(CAST(IV.issuevariant_rating AS UNSIGNED)) AS issuevariant_rating_num", columns) + case entity.IssueEarliestTargetRemediationDate: + columns = fmt.Sprintf("%s, MIN(IM.issuematch_target_remediation_date) AS agg_earliest_target_remediation_date", columns) } } return columns @@ -250,9 +265,7 @@ func (s *SqlDatabase) GetIssuesWithAggregations(filter *entity.IssueFilter, orde }) baseCiQuery := ` - SELECT I.*, SUM(CI.componentinstance_count) AS agg_affected_component_instances %s FROM Issue I - LEFT JOIN IssueMatch IM on I.issue_id = IM.issuematch_issue_id - LEFT JOIN ComponentInstance CI on IM.issuematch_component_instance_id = CI.componentinstance_id + SELECT I.issue_id, SUM(CI.componentinstance_count) AS agg_affected_component_instances %s FROM Issue I %s %s GROUP BY I.issue_id %s ORDER BY %s LIMIT ? @@ -271,11 +284,6 @@ func (s *SqlDatabase) GetIssuesWithAggregations(filter *entity.IssueFilter, orde min(issuematch_created_at) agg_earliest_discovery_date %s FROM Issue I - LEFT JOIN IssueMatch IM on I.issue_id = IM.issuematch_issue_id - LEFT JOIN ComponentInstance CI ON CI.componentinstance_id = IM.issuematch_component_instance_id - LEFT JOIN ComponentVersion CV ON CI.componentinstance_component_version_id = CV.componentversion_id - LEFT JOIN Service S ON S.service_id = CI.componentinstance_service_id - LEFT JOIN ComponentVersionIssue CVI ON I.issue_id = CVI.componentversionissue_issue_id %s %s GROUP BY I.issue_id %s ORDER BY %s LIMIT ? @@ -288,33 +296,34 @@ func (s *SqlDatabase) GetIssuesWithAggregations(filter *entity.IssueFilter, orde Aggs AS ( %s ) - SELECT A.*, CIC.* + SELECT A.*, CIC.agg_affected_component_instances FROM ComponentInstanceCounts CIC - JOIN Aggs A ON CIC.issue_id = A.issue_id; + JOIN Aggs A ON CIC.issue_id = A.issue_id + ORDER BY %s; ` filter = ensureIssueFilter(filter) - joins := getIssueJoins(filter, order) + joins := getIssueJoinsExtended(filter, order, true) cursorFields, err := DecodeCursor(filter.Paginated.After) if err != nil { return nil, err } columns := getIssueColumns(order) + aggOrder := lo.Filter(order, func(o entity.Order, _ int) bool { + return o.By != entity.IssueEarliestTargetRemediationDate + }) + aggColumns := getIssueColumns(aggOrder) defaultOrder := GetDefaultOrder(order, entity.IssueId, entity.OrderDirectionAsc) orderStr := CreateOrderString(defaultOrder) whereClause := getIssueFilterWhereClause(filter) - cursorQuery := CreateCursorQuery("", cursorFields) - filterStr := issueObject.GetFilterQuery(filter) - if filterStr != "" && cursorQuery != "" { - cursorQuery = fmt.Sprintf(" AND (%s)", cursorQuery) - } + cursorQuery := getIssueCursorQuery(filter, cursorFields) ciQuery := fmt.Sprintf(baseCiQuery, columns, joins, whereClause, cursorQuery, orderStr) - aggQuery := fmt.Sprintf(baseAggQuery, columns, joins, whereClause, cursorQuery, orderStr) - query := fmt.Sprintf(baseQuery, ciQuery, aggQuery) + aggQuery := fmt.Sprintf(baseAggQuery, aggColumns, joins, whereClause, cursorQuery, orderStr) + query := fmt.Sprintf(baseQuery, ciQuery, aggQuery, orderStr) stmt, err := s.db.Preparex(query) if err != nil { @@ -351,7 +360,7 @@ func (s *SqlDatabase) GetIssuesWithAggregations(filter *entity.IssueFilter, orde ivRating = e.IssueVariantRow.RatingNumerical.Int64 } - cursor, _ := EncodeCursor(WithIssue(defaultOrder, issue.Issue, ivRating)) + cursor, _ := EncodeCursor(WithIssue(defaultOrder, issue.Issue, ivRating, issue.EarliestTargetRemediationDate)) sr := entity.IssueResult{ WithCursor: entity.WithCursor{ @@ -469,8 +478,12 @@ func (s *SqlDatabase) GetAllIssueCursors(filter *entity.IssueFilter, order []ent if row.IssueVariantRow != nil { ivRating = row.IssueVariantRow.RatingNumerical.Int64 } + var etrd time.Time + if row.IssueAggregationsRow != nil { + etrd = GetTimeValue(row.IssueAggregationsRow.EarliestTargetRemediationDate) + } - cursor, _ := EncodeCursor(WithIssue(order, issue, ivRating)) + cursor, _ := EncodeCursor(WithIssue(order, issue, ivRating, etrd)) return cursor }), nil @@ -508,8 +521,12 @@ func (s *SqlDatabase) GetIssues(filter *entity.IssueFilter, order []entity.Order if e.IssueVariantRow != nil { ivRating = e.IssueVariantRow.RatingNumerical.Int64 } + var etrd time.Time + if e.IssueAggregationsRow != nil { + etrd = GetTimeValue(e.IssueAggregationsRow.EarliestTargetRemediationDate) + } - cursor, _ := EncodeCursor(WithIssue(order, issue, ivRating)) + cursor, _ := EncodeCursor(WithIssue(order, issue, ivRating, etrd)) sr := entity.IssueResult{ WithCursor: entity.WithCursor{ diff --git a/internal/database/mariadb/issue_test.go b/internal/database/mariadb/issue_test.go index b40c219f..1618a858 100644 --- a/internal/database/mariadb/issue_test.go +++ b/internal/database/mariadb/issue_test.go @@ -467,7 +467,7 @@ var _ = Describe("Issue", Label("database", "Issue"), func() { }, []entity.Order{}, func(entries []entity.IssueResult) string { - after, _ := mariadb.EncodeCursor(mariadb.WithIssue([]entity.Order{}, *entries[len(entries)-1].Issue, 0)) + after, _ := mariadb.EncodeCursor(mariadb.WithIssue([]entity.Order{}, *entries[len(entries)-1].Issue, 0, time.Time{})) return after }, len(seedCollection.IssueRows), @@ -1129,5 +1129,83 @@ var _ = Describe("Ordering Issues", Label("IssueOrder"), func() { } }) }) + + It("can order by earliest target remediation date", func() { + issue1Row := test.NewFakeIssue() + issue1Row.PrimaryName = sql.NullString{String: "CVE-2024-53068", Valid: true} + issue1Id, _ := seeder.InsertFakeIssue(issue1Row) + issue1Row.Id = sql.NullInt64{Int64: issue1Id, Valid: true} + + issue2Row := test.NewFakeIssue() + issue2Row.PrimaryName = sql.NullString{String: "CVE-2024-53061", Valid: true} + issue2Id, _ := seeder.InsertFakeIssue(issue2Row) + issue2Row.Id = sql.NullInt64{Int64: issue2Id, Valid: true} + + issue3Row := test.NewFakeIssue() + issue3Row.PrimaryName = sql.NullString{String: "CVE-2024-53059", Valid: true} + issue3Id, _ := seeder.InsertFakeIssue(issue3Row) + issue3Row.Id = sql.NullInt64{Int64: issue3Id, Valid: true} + + date1, _ := time.Parse(time.RFC3339, "2026-02-17T01:10:57Z") + date2, _ := time.Parse(time.RFC3339, "2026-02-26T08:37:53Z") + date3, _ := time.Parse(time.RFC3339, "2026-02-17T07:15:47Z") + + userId := seedCollection.UserRows[0].Id + ciId := seedCollection.ComponentInstanceRows[0].Id + + im1 := test.NewFakeIssueMatch() + im1.IssueId = issue1Row.Id + im1.TargetRemediationDate = sql.NullTime{Time: date1, Valid: true} + im1.UserId = userId + im1.ComponentInstanceId = ciId + seeder.InsertFakeIssueMatch(im1) + + im2 := test.NewFakeIssueMatch() + im2.IssueId = issue2Row.Id + im2.TargetRemediationDate = sql.NullTime{Time: date2, Valid: true} + im2.UserId = userId + im2.ComponentInstanceId = ciId + seeder.InsertFakeIssueMatch(im2) + + im3 := test.NewFakeIssueMatch() + im3.IssueId = issue3Row.Id + im3.TargetRemediationDate = sql.NullTime{Time: date3, Valid: true} + im3.UserId = userId + im3.ComponentInstanceId = ciId + seeder.InsertFakeIssueMatch(im3) + + order := []entity.Order{ + {By: entity.IssueEarliestTargetRemediationDate, Direction: entity.OrderDirectionDesc}, + {By: entity.IssuePrimaryName, Direction: entity.OrderDirectionAsc}, + } + + res, err := db.GetIssuesWithAggregations(nil, order) + Expect(err).Should(BeNil()) + + // Expected order (DESC Date): + // 1. Issue 2 (Feb 26) + // 2. Issue 3 (Feb 17 07:15) + // 3. Issue 1 (Feb 17 01:10) + + var idx1, idx2, idx3 int = -1, -1, -1 + for i, r := range res { + if r.Issue.Id == issue1Row.Id.Int64 { + idx1 = i + } + if r.Issue.Id == issue2Row.Id.Int64 { + idx2 = i + } + if r.Issue.Id == issue3Row.Id.Int64 { + idx3 = i + } + } + + Expect(idx1).ToNot(Equal(-1)) + Expect(idx2).ToNot(Equal(-1)) + Expect(idx3).ToNot(Equal(-1)) + + Expect(idx2 < idx3).To(BeTrue(), "Issue 2 (Feb 26) should come before Issue 3 (Feb 17 07:15)") + Expect(idx3 < idx1).To(BeTrue(), "Issue 3 (Feb 17 07:15) should come before Issue 1 (Feb 17 01:10)") + }) }) }) diff --git a/internal/database/mariadb/order.go b/internal/database/mariadb/order.go index ad8d3879..5a7c23a5 100644 --- a/internal/database/mariadb/order.go +++ b/internal/database/mariadb/order.go @@ -59,6 +59,8 @@ func ColumnName(f entity.OrderByField) string { return "issuematch_rating" case entity.IssueMatchTargetRemediationDate: return "issuematch_target_remediation_date" + case entity.IssueEarliestTargetRemediationDate: + return "agg_earliest_target_remediation_date" case entity.SupportGroupId: return "supportgroup_id" case entity.SupportGroupCcrn: diff --git a/internal/entity/order.go b/internal/entity/order.go index 956ed8ed..4d6320f2 100644 --- a/internal/entity/order.go +++ b/internal/entity/order.go @@ -37,6 +37,7 @@ const ( IssueMatchId IssueMatchRating IssueMatchTargetRemediationDate + IssueEarliestTargetRemediationDate CriticalCount HighCount