From f36f07802082ba2a53c3bccf552b59ed5fc1113e Mon Sep 17 00:00:00 2001 From: Valiantsin Tsimoshyk Date: Thu, 25 Jun 2026 18:40:33 +0200 Subject: [PATCH] fix(db): fix vulnerability count mismatches across service group and image views Signed-off-by: Valiantsin Tsimoshyk --- .../graphql/graph/baseResolver/component.go | 17 +- .../api/graphql/graph/baseResolver/image.go | 25 +-- .../graph/baseResolver/vulnerability.go | 53 +++++-- internal/app/component/component_handler.go | 6 +- .../app/component/component_handler_events.go | 20 +-- internal/database/mariadb/component.go | 26 +-- internal/database/mariadb/image_batch.go | 35 ++-- internal/database/mariadb/migration.go | 1 - ...ix_vulnerability_count_mismatches.down.sql | 150 ++++++++++++++++++ ..._fix_vulnerability_count_mismatches.up.sql | 94 +++++++++++ internal/database/mariadb/test/fixture.go | 7 +- internal/e2e/image_query_test.go | 15 ++ 12 files changed, 357 insertions(+), 92 deletions(-) create mode 100644 internal/database/mariadb/migrations/20260625100000_fix_vulnerability_count_mismatches.down.sql create mode 100644 internal/database/mariadb/migrations/20260625100000_fix_vulnerability_count_mismatches.up.sql diff --git a/internal/api/graphql/graph/baseResolver/component.go b/internal/api/graphql/graph/baseResolver/component.go index 4647f4660..aa7ef31bd 100644 --- a/internal/api/graphql/graph/baseResolver/component.go +++ b/internal/api/graphql/graph/baseResolver/component.go @@ -204,19 +204,24 @@ func ComponentIssueCountsBaseResolver( } } - f := &entity.ComponentFilter{ - Id: componentId, - ServiceCCRN: filter.ServiceCcrn, + if len(componentId) == 0 { + return &model.SeverityCounts{}, nil } - var severityCounts model.SeverityCounts + ids := make([]int64, 0, len(componentId)) + for _, id := range componentId { + if id != nil { + ids = append(ids, *id) + } + } - counts, err := app.GetComponentVulnerabilityCounts(ctx, f) + countsMap, err := app.GetIssueCountsByComponentIDs(ctx, ids, filter.ServiceCcrn) if err != nil { return nil, ToGraphQLError(err) } - for _, c := range counts { + var severityCounts model.SeverityCounts + for _, c := range countsMap { severityCounts.Critical += int(c.Critical) severityCounts.High += int(c.High) severityCounts.Medium += int(c.Medium) diff --git a/internal/api/graphql/graph/baseResolver/image.go b/internal/api/graphql/graph/baseResolver/image.go index ad3320af6..cf3602b18 100644 --- a/internal/api/graphql/graph/baseResolver/image.go +++ b/internal/api/graphql/graph/baseResolver/image.go @@ -78,23 +78,24 @@ func ImageBaseResolver( edges = append(edges, &edge) } + // Parse component IDs once; reused for both per-image preloads and ImageConnection.counts + componentIDs := make([]int64, 0, len(edges)) + for _, edge := range edges { + id, err := strconv.ParseInt(edge.Node.ID, 10, 64) + if err != nil { + logrus.WithField("id", edge.Node.ID).Warn("ImageBaseResolver: failed to parse component ID for batch preload") + continue + } + + componentIDs = append(componentIDs, id) + } + // Batch pre-load for nested fields needVersions := lo.Contains(requestedFields, "edges.node.versions") needVulnCounts := lo.Contains(requestedFields, "edges.node.vulnerabilityCounts") needVulnerabilities := containsField(requestedFields, "edges.node.vulnerabilities") - if (needVersions || needVulnCounts || needVulnerabilities) && len(edges) > 0 { - componentIDs := make([]int64, 0, len(edges)) - for _, edge := range edges { - id, err := strconv.ParseInt(edge.Node.ID, 10, 64) - if err != nil { - logrus.WithField("id", edge.Node.ID).Warn("ImageBaseResolver: failed to parse component ID for batch preload") - continue - } - - componentIDs = append(componentIDs, id) - } - + if (needVersions || needVulnCounts || needVulnerabilities) && len(componentIDs) > 0 { var ( versionsMap map[int64][]entity.ComponentVersionResult issueCountsMap map[int64]entity.IssueSeverityCounts diff --git a/internal/api/graphql/graph/baseResolver/vulnerability.go b/internal/api/graphql/graph/baseResolver/vulnerability.go index e38fd5609..c68613093 100644 --- a/internal/api/graphql/graph/baseResolver/vulnerability.go +++ b/internal/api/graphql/graph/baseResolver/vulnerability.go @@ -149,23 +149,44 @@ func VulnerabilityBaseResolver(app app.Heureka, ctx context.Context, } if lo.Contains(requestedFields, "counts") { - icFilter := &model.IssueFilter{ - SupportGroupCcrn: filter.SupportGroup, - IssueType: []*model.IssueTypes{lo.ToPtr(model.IssueTypesVulnerability)}, - IssueMatchStatus: []*model.IssueMatchStatusValues{ - lo.ToPtr(model.IssueMatchStatusValuesNew), - }, - Search: filter.Search, - ServiceCcrn: filter.Service, - ComponentVersionID: util.ConvertIntToStrSlice(f.ComponentVersionId), - AllServices: new(true), - } + var severityCounts *model.SeverityCounts - severityCounts, err := IssueCountsBaseResolver(app, ctx, icFilter, &model.NodeParent{ - ParentName: model.VulnerabilityNodeName, - }) - if err != nil { - return nil, NewResolverError("VulnerabilityBaseResolver", err.Error()) + if parent != nil && parent.ParentName == model.ImageNodeName { + // For an Image parent, count distinct vulnerabilities per component using + // the same mvVulnerabilityList source as the vulnerability list so the + // severity breakdown is consistent with the items shown in the list. + countsMap, err := app.GetIssueCountsByComponentIDs(ctx, []int64{*f.ComponentId[0]}, filter.Service) + if err != nil { + return nil, NewResolverError("VulnerabilityBaseResolver", err.Error()) + } + + sc := model.NewSeverityCounts(&entity.IssueSeverityCounts{}) + if counts, ok := countsMap[*f.ComponentId[0]]; ok { + sc = model.NewSeverityCounts(&counts) + } + + severityCounts = &sc + } else { + icFilter := &model.IssueFilter{ + SupportGroupCcrn: filter.SupportGroup, + IssueType: []*model.IssueTypes{lo.ToPtr(model.IssueTypesVulnerability)}, + IssueMatchStatus: []*model.IssueMatchStatusValues{ + lo.ToPtr(model.IssueMatchStatusValuesNew), + }, + Search: filter.Search, + ServiceCcrn: filter.Service, + ComponentVersionID: util.ConvertIntToStrSlice(f.ComponentVersionId), + AllServices: new(true), + } + + sc, err := IssueCountsBaseResolver(app, ctx, icFilter, &model.NodeParent{ + ParentName: model.VulnerabilityNodeName, + }) + if err != nil { + return nil, NewResolverError("VulnerabilityBaseResolver", err.Error()) + } + + severityCounts = sc } connection.Counts = severityCounts diff --git a/internal/app/component/component_handler.go b/internal/app/component/component_handler.go index 65598285a..60dd7b74d 100644 --- a/internal/app/component/component_handler.go +++ b/internal/app/component/component_handler.go @@ -314,7 +314,7 @@ func (cs *componentHandler) GetComponentVulnerabilityCounts( filter *entity.ComponentFilter, ) ([]entity.IssueSeverityCounts, error) { l := logrus.WithFields(logrus.Fields{ - "event": GetComponentIssueSeverityCountsEventName, + "event": "GetComponentVulnerabilityCounts", "filter": filter, }) @@ -327,9 +327,5 @@ func (cs *componentHandler) GetComponentVulnerabilityCounts( ) } - cs.eventRegistry.PushEvent( - &GetComponentIssueSeverityCountsEvent{Filter: filter, Counts: counts}, - ) - return counts, nil } diff --git a/internal/app/component/component_handler_events.go b/internal/app/component/component_handler_events.go index 927ab464b..18a84ab08 100644 --- a/internal/app/component/component_handler_events.go +++ b/internal/app/component/component_handler_events.go @@ -13,12 +13,11 @@ import ( ) const ( - ListComponentsEventName event.EventName = "ListComponents" - CreateComponentEventName event.EventName = "CreateComponent" - UpdateComponentEventName event.EventName = "UpdateComponent" - DeleteComponentEventName event.EventName = "DeleteComponent" - ListComponentCcrnsEventName event.EventName = "ListComponentCcrns" - GetComponentIssueSeverityCountsEventName event.EventName = "GetComponentIssueSeverityCounts" + ListComponentsEventName event.EventName = "ListComponents" + CreateComponentEventName event.EventName = "CreateComponent" + UpdateComponentEventName event.EventName = "UpdateComponent" + DeleteComponentEventName event.EventName = "DeleteComponent" + ListComponentCcrnsEventName event.EventName = "ListComponentCcrns" ) type ListComponentsEvent struct { @@ -65,15 +64,6 @@ func (e *ListComponentCcrnsEvent) Name() event.EventName { return ListComponentCcrnsEventName } -type GetComponentIssueSeverityCountsEvent struct { - Filter *entity.ComponentFilter - Counts []entity.IssueSeverityCounts -} - -func (e *GetComponentIssueSeverityCountsEvent) Name() event.EventName { - return GetComponentIssueSeverityCountsEventName -} - // OnComponentCreateAuthz is a handler for the CreateComponentEvent // It creates an OpenFGA relation tuple for the component and the current user func OnComponentCreateAuthz(db database.Database, e event.Event, authz openfga.Authorization) { diff --git a/internal/database/mariadb/component.go b/internal/database/mariadb/component.go index 3c3f7bc70..69c53eea0 100644 --- a/internal/database/mariadb/component.go +++ b/internal/database/mariadb/component.go @@ -74,17 +74,6 @@ var componentObject = DbObject[*entity.Component, *entity.ComponentFilter, entit return !f.UseMvComponentService && needSingleComponentByServiceVulnerabilityCounts(f, order) }, }, - { - Name: "mvACBSVC", - Type: LeftJoin, - Table: "mvAllComponentsByServiceVulnerabilityCounts CVR", - On: "S.service_id = CVR.service_id", - DependsOn: []string{"S"}, - Condition: func(f *entity.ComponentFilter, order *Order) bool { - return false - }, - }, - // --- Optimized MV path: mvComponentService replaces the CV→CI→S chain --- // Uses a pre-computed junction table of (service_id, component_id) to avoid // scanning millions of ComponentInstance rows at query time. @@ -192,24 +181,16 @@ func (s *SqlDatabase) CountComponentVulnerabilities( filter = EnsureFilter(filter) query := ` - SELECT CVR.critical_count, CVR.high_count, CVR.medium_count, CVR.low_count, CVR.none_count FROM %s AS CVR + SELECT CVR.critical_count, CVR.high_count, CVR.medium_count, CVR.low_count, CVR.none_count + FROM mvSingleComponentByServiceVulnerabilityCounts AS CVR ` joins := "" - groupBy := "" - - if len(filter.Id) == 0 && len(filter.Repository) == 0 { - query = fmt.Sprintf(query, "mvAllComponentsByServiceVulnerabilityCounts") - } else { - query = fmt.Sprintf(query, "mvSingleComponentByServiceVulnerabilityCounts") - groupBy = "GROUP BY CVR.component_id" - } if len(filter.ServiceCCRN) > 0 { joins = fmt.Sprintf("%s INNER JOIN Service S ON S.service_id = CVR.service_id", joins) fl = append(fl, buildFilterQuery(filter.ServiceCCRN, "S.service_ccrn = ?", OP_OR)) - filterParameters = buildQueryParameters(filterParameters, filter.ServiceCCRN) } @@ -217,7 +198,6 @@ func (s *SqlDatabase) CountComponentVulnerabilities( joins = fmt.Sprintf("%s INNER JOIN Component C ON C.component_id = CVR.component_id", joins) fl = append(fl, buildFilterQuery(filter.Repository, "C.component_repository = ?", OP_OR)) - filterParameters = buildQueryParameters(filterParameters, filter.Repository) } @@ -233,7 +213,7 @@ func (s *SqlDatabase) CountComponentVulnerabilities( query = fmt.Sprintf("%s WHERE %s", query, filterStr) } - query = fmt.Sprintf("%s %s", query, groupBy) + query = fmt.Sprintf("%s GROUP BY CVR.component_id", query) stmt, err := s.db.PreparexContext(ctx, query) if err != nil { diff --git a/internal/database/mariadb/image_batch.go b/internal/database/mariadb/image_batch.go index 207920b62..bb2c088e6 100644 --- a/internal/database/mariadb/image_batch.go +++ b/internal/database/mariadb/image_batch.go @@ -127,9 +127,11 @@ func (s *SqlDatabase) GetVersionsByComponentIDs(ctx context.Context, componentID return result, nil } -// GetIssueCountsByComponentIDs returns issue severity counts grouped by component ID in a single query. -// This eliminates N+1 queries when loading vulnerability counts for multiple images. -// The optional serviceCCRN filter restricts counts to the specified services. +// GetIssueCountsByComponentIDs returns vulnerability severity counts grouped by component ID. +// Counts are derived from the same mvVulnerabilityList source used by GetVulnerabilitiesByComponentIDs, +// so the badge counts always match the number of items shown in the vulnerability list. +// The optional serviceCCRN filter restricts counts to vulnerabilities active in the specified services +// (via mvVulnerabilityService). func (s *SqlDatabase) GetIssueCountsByComponentIDs(ctx context.Context, componentIDs []int64, serviceCCRN []*string) (map[int64]entity.IssueSeverityCounts, error) { l := logrus.WithFields(logrus.Fields{ "event": "database.GetIssueCountsByComponentIDs", @@ -141,15 +143,21 @@ func (s *SqlDatabase) GetIssueCountsByComponentIDs(ctx context.Context, componen } query := sq.Select( - "CVR.component_id", - "SUM(CVR.critical_count) as critical_count", - "SUM(CVR.high_count) as high_count", - "SUM(CVR.medium_count) as medium_count", - "SUM(CVR.low_count) as low_count", - "SUM(CVR.none_count) as none_count", + "CV.componentversion_component_id", + "COUNT(DISTINCT CASE WHEN MVL.max_severity = 'Critical' THEN I.issue_id END) as critical_count", + "COUNT(DISTINCT CASE WHEN MVL.max_severity = 'High' THEN I.issue_id END) as high_count", + "COUNT(DISTINCT CASE WHEN MVL.max_severity = 'Medium' THEN I.issue_id END) as medium_count", + "COUNT(DISTINCT CASE WHEN MVL.max_severity = 'Low' THEN I.issue_id END) as low_count", + "COUNT(DISTINCT CASE WHEN MVL.max_severity = 'None' THEN I.issue_id END) as none_count", ). - From("mvSingleComponentByServiceVulnerabilityCounts CVR"). - Where(sq.Eq{"CVR.component_id": componentIDs}) + From("ComponentVersionIssue CVI"). + Join("ComponentVersion CV ON CVI.componentversionissue_component_version_id = CV.componentversion_id"). + Join("Issue I ON CVI.componentversionissue_issue_id = I.issue_id"). + Join("mvVulnerabilityList MVL ON I.issue_id = MVL.issue_id"). + Where(sq.Eq{"CV.componentversion_component_id": componentIDs}). + Where("CVI.componentversionissue_deleted_at IS NULL"). + Where("CV.componentversion_deleted_at IS NULL"). + Where("I.issue_deleted_at IS NULL") if len(serviceCCRN) > 0 { nonNilCCRNs := make([]string, 0, len(serviceCCRN)) @@ -161,12 +169,13 @@ func (s *SqlDatabase) GetIssueCountsByComponentIDs(ctx context.Context, componen if len(nonNilCCRNs) > 0 { query = query. - Join("Service S ON CVR.service_id = S.service_id"). + Join("mvVulnerabilityService MVS ON I.issue_id = MVS.issue_id"). + Join("Service S ON MVS.service_id = S.service_id"). Where(sq.Eq{"S.service_ccrn": nonNilCCRNs}) } } - query = query.GroupBy("CVR.component_id") + query = query.GroupBy("CV.componentversion_component_id") sqlStr, args, err := query.ToSql() if err != nil { diff --git a/internal/database/mariadb/migration.go b/internal/database/mariadb/migration.go index 8ea841a41..a3ee91ff0 100644 --- a/internal/database/mariadb/migration.go +++ b/internal/database/mariadb/migration.go @@ -150,7 +150,6 @@ func registerEvents(cfg util.Config) error { events := []string{ "refresh_mvServiceIssueCounts", "refresh_mvSingleComponentByServiceVulnerabilityCounts", - "refresh_mvAllComponentsByServiceVulnerabilityCounts", "refresh_mvCountIssueRatingsUniqueService", "refresh_mvCountIssueRatingsService", "refresh_mvCountIssueRatingsServiceWithoutSupportGroup", diff --git a/internal/database/mariadb/migrations/20260625100000_fix_vulnerability_count_mismatches.down.sql b/internal/database/mariadb/migrations/20260625100000_fix_vulnerability_count_mismatches.down.sql new file mode 100644 index 000000000..facaed5a2 --- /dev/null +++ b/internal/database/mariadb/migrations/20260625100000_fix_vulnerability_count_mismatches.down.sql @@ -0,0 +1,150 @@ +-- SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +-- SPDX-License-Identifier: Apache-2.0 + +-- Revert 20260625100000_fix_vulnerability_count_mismatches + +-- 1. Restore mvCountIssueRatingsService (pre-fix: issuevariant_rating, service_id in dedup key) + +DROP PROCEDURE IF EXISTS refresh_mvCountIssueRatingsService_proc; + +CREATE PROCEDURE refresh_mvCountIssueRatingsService_proc() +BEGIN + SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; + + UPDATE mvCountIssueRatingsService + SET is_active = 0 + WHERE is_active = 1; + + INSERT INTO mvCountIssueRatingsService ( + supportgroup_ccrn, + critical_count, + high_count, + medium_count, + low_count, + none_count, + is_active + ) + SELECT + COALESCE(SG.supportgroup_ccrn, 'UNKNOWN'), + COUNT(DISTINCT CASE WHEN IV.issuevariant_rating = 'Critical' + THEN CONCAT(CI.componentinstance_component_version_id, ',', I.issue_id, ',', S.service_id) END), + COUNT(DISTINCT CASE WHEN IV.issuevariant_rating = 'High' + THEN CONCAT(CI.componentinstance_component_version_id, ',', I.issue_id, ',', S.service_id) END), + COUNT(DISTINCT CASE WHEN IV.issuevariant_rating = 'Medium' + THEN CONCAT(CI.componentinstance_component_version_id, ',', I.issue_id, ',', S.service_id) END), + COUNT(DISTINCT CASE WHEN IV.issuevariant_rating = 'Low' + THEN CONCAT(CI.componentinstance_component_version_id, ',', I.issue_id, ',', S.service_id) END), + COUNT(DISTINCT CASE WHEN IV.issuevariant_rating = 'None' + THEN CONCAT(CI.componentinstance_component_version_id, ',', I.issue_id, ',', S.service_id) END), + 1 + FROM Issue I + LEFT JOIN IssueVariant IV ON IV.issuevariant_issue_id = I.issue_id + RIGHT 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 Service S ON S.service_id = CI.componentinstance_service_id + LEFT JOIN SupportGroupService SGS ON SGS.supportgroupservice_service_id = CI.componentinstance_service_id + LEFT JOIN SupportGroup SG ON SGS.supportgroupservice_support_group_id = SG.supportgroup_id + WHERE I.issue_deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM Remediation R + WHERE R.remediation_service_id = S.service_id + AND R.remediation_issue_id = I.issue_id + AND R.remediation_deleted_at IS NULL + AND (R.remediation_expiration_date IS NULL OR R.remediation_expiration_date >= CURDATE()) + ) + GROUP BY SG.supportgroup_ccrn + ON DUPLICATE KEY UPDATE + critical_count = VALUES(critical_count), + high_count = VALUES(high_count), + medium_count = VALUES(medium_count), + low_count = VALUES(low_count), + none_count = VALUES(none_count), + is_active = 1; + + DELETE FROM mvCountIssueRatingsService + WHERE is_active = 0; +END; + +CALL refresh_mvCountIssueRatingsService_proc(); + +-- 2. mvCountIssueRatingsComponentVersion was not changed; nothing to revert. + +-- 3. Recreate mvAllComponentsByServiceVulnerabilityCounts + +CREATE TABLE IF NOT EXISTS mvAllComponentsByServiceVulnerabilityCounts ( + service_id INT UNSIGNED NOT NULL, + critical_count INT DEFAULT 0, + high_count INT DEFAULT 0, + medium_count INT DEFAULT 0, + low_count INT DEFAULT 0, + none_count INT DEFAULT 0, + is_active TINYINT(1) NOT NULL DEFAULT 1, + PRIMARY KEY (service_id), + KEY idx_mvAllComponents_active (is_active), + CONSTRAINT fk_mvallcomponentsbyservicevulnerabilitycounts_service_id FOREIGN KEY (service_id) REFERENCES Service (service_id) +); + +DROP PROCEDURE IF EXISTS refresh_mvAllComponentsByServiceVulnerabilityCounts_proc; + +CREATE PROCEDURE refresh_mvAllComponentsByServiceVulnerabilityCounts_proc() +BEGIN + SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; + + UPDATE mvAllComponentsByServiceVulnerabilityCounts + SET is_active = 0; + + INSERT INTO mvAllComponentsByServiceVulnerabilityCounts ( + service_id, + critical_count, + high_count, + medium_count, + low_count, + none_count, + is_active + ) + SELECT + CI.componentinstance_service_id, + COUNT(DISTINCT CASE WHEN IV.issuevariant_rating = 'Critical' + THEN CONCAT(CV.componentversion_component_id, ',', IV.issuevariant_issue_id) END), + COUNT(DISTINCT CASE WHEN IV.issuevariant_rating = 'High' + THEN CONCAT(CV.componentversion_component_id, ',', IV.issuevariant_issue_id) END), + COUNT(DISTINCT CASE WHEN IV.issuevariant_rating = 'Medium' + THEN CONCAT(CV.componentversion_component_id, ',', IV.issuevariant_issue_id) END), + COUNT(DISTINCT CASE WHEN IV.issuevariant_rating = 'Low' + THEN CONCAT(CV.componentversion_component_id, ',', IV.issuevariant_issue_id) END), + COUNT(DISTINCT CASE WHEN IV.issuevariant_rating = 'None' + THEN CONCAT(CV.componentversion_component_id, ',', IV.issuevariant_issue_id) END), + 1 + FROM IssueMatch IM + JOIN ComponentInstance CI ON CI.componentinstance_id = IM.issuematch_component_instance_id + JOIN ComponentVersion CV ON CV.componentversion_id = CI.componentinstance_component_version_id + JOIN IssueVariant IV ON IV.issuevariant_issue_id = IM.issuematch_issue_id + JOIN Issue I ON I.issue_id = IV.issuevariant_issue_id + WHERE IM.issuematch_status = 'new' + AND I.issue_type = 'Vulnerability' + AND IM.issuematch_deleted_at IS NULL + AND I.issue_deleted_at IS NULL + AND CI.componentinstance_deleted_at IS NULL + AND CV.componentversion_deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM Remediation R + WHERE R.remediation_service_id = CI.componentinstance_service_id + AND R.remediation_issue_id = I.issue_id + AND R.remediation_deleted_at IS NULL + AND (R.remediation_expiration_date IS NULL OR R.remediation_expiration_date >= CURDATE()) + ) + GROUP BY CI.componentinstance_service_id + ON DUPLICATE KEY UPDATE + critical_count = VALUES(critical_count), + high_count = VALUES(high_count), + medium_count = VALUES(medium_count), + low_count = VALUES(low_count), + none_count = VALUES(none_count), + is_active = 1; +END; + +CALL refresh_mvAllComponentsByServiceVulnerabilityCounts_proc(); + +INSERT IGNORE INTO post_migration_procedure_registry (name) +VALUES ('refresh_mvAllComponentsByServiceVulnerabilityCounts_proc'); + diff --git a/internal/database/mariadb/migrations/20260625100000_fix_vulnerability_count_mismatches.up.sql b/internal/database/mariadb/migrations/20260625100000_fix_vulnerability_count_mismatches.up.sql new file mode 100644 index 000000000..594d3e926 --- /dev/null +++ b/internal/database/mariadb/migrations/20260625100000_fix_vulnerability_count_mismatches.up.sql @@ -0,0 +1,94 @@ +-- SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +-- SPDX-License-Identifier: Apache-2.0 + +-- Fix vulnerability count mismatches across service group and image list views. +-- +-- Two changes: one procedure fix and one unused table/procedure removal: +-- +-- 1. refresh_mvCountIssueRatingsService_proc (service group total): +-- Was counting the same (cv, issue) pair once per service it appeared in (service_id +-- was included in the CONCAT dedup key). A vulnerability shared across N services in a +-- support group counted N times in the group total, inflating it above the sum of the +-- per-service badges. Fix: drop service_id from the dedup key so each (cv, issue) pair +-- is counted once per support group, matching per-service badge semantics. +-- +-- 2. mvAllComponentsByServiceVulnerabilityCounts (image list total): +-- The table is no longer reachable: the mvACBSVC join in componentObject had +-- Condition: return false, and CountComponentVulnerabilities now always uses +-- mvSingleComponentByServiceVulnerabilityCounts. Drop the event, procedure, and table, +-- and remove it from the post-migration registry so startup no longer checks for it. + +-- 1. Fix mvCountIssueRatingsService (drop service_id from dedup key) + +DROP PROCEDURE IF EXISTS refresh_mvCountIssueRatingsService_proc; + +CREATE PROCEDURE refresh_mvCountIssueRatingsService_proc() +BEGIN + SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; + + UPDATE mvCountIssueRatingsService + SET is_active = 0 + WHERE is_active = 1; + + INSERT INTO mvCountIssueRatingsService ( + supportgroup_ccrn, + critical_count, + high_count, + medium_count, + low_count, + none_count, + is_active + ) + SELECT + COALESCE(SG.supportgroup_ccrn, 'UNKNOWN'), + COUNT(DISTINCT CASE WHEN IV.issuevariant_rating = 'Critical' + THEN CONCAT(CI.componentinstance_component_version_id, ',', I.issue_id) END), + COUNT(DISTINCT CASE WHEN IV.issuevariant_rating = 'High' + THEN CONCAT(CI.componentinstance_component_version_id, ',', I.issue_id) END), + COUNT(DISTINCT CASE WHEN IV.issuevariant_rating = 'Medium' + THEN CONCAT(CI.componentinstance_component_version_id, ',', I.issue_id) END), + COUNT(DISTINCT CASE WHEN IV.issuevariant_rating = 'Low' + THEN CONCAT(CI.componentinstance_component_version_id, ',', I.issue_id) END), + COUNT(DISTINCT CASE WHEN IV.issuevariant_rating = 'None' + THEN CONCAT(CI.componentinstance_component_version_id, ',', I.issue_id) END), + 1 + FROM Issue I + LEFT JOIN IssueVariant IV ON IV.issuevariant_issue_id = I.issue_id + RIGHT 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 Service S ON S.service_id = CI.componentinstance_service_id + LEFT JOIN SupportGroupService SGS ON SGS.supportgroupservice_service_id = CI.componentinstance_service_id + LEFT JOIN SupportGroup SG ON SGS.supportgroupservice_support_group_id = SG.supportgroup_id + WHERE I.issue_deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM Remediation R + WHERE R.remediation_service_id = S.service_id + AND R.remediation_issue_id = I.issue_id + AND R.remediation_deleted_at IS NULL + AND (R.remediation_expiration_date IS NULL OR R.remediation_expiration_date >= CURDATE()) + ) + GROUP BY SG.supportgroup_ccrn + ON DUPLICATE KEY UPDATE + critical_count = VALUES(critical_count), + high_count = VALUES(high_count), + medium_count = VALUES(medium_count), + low_count = VALUES(low_count), + none_count = VALUES(none_count), + is_active = 1; + + DELETE FROM mvCountIssueRatingsService + WHERE is_active = 0; +END; + +CALL refresh_mvCountIssueRatingsService_proc(); + +-- 2. No change to mvCountIssueRatingsComponentVersion; the procedure is unchanged. + +-- 3. Drop mvAllComponentsByServiceVulnerabilityCounts + +DROP EVENT IF EXISTS refresh_mvAllComponentsByServiceVulnerabilityCounts; +DROP PROCEDURE IF EXISTS refresh_mvAllComponentsByServiceVulnerabilityCounts_proc; +SET FOREIGN_KEY_CHECKS = 0; +DROP TABLE IF EXISTS mvAllComponentsByServiceVulnerabilityCounts; +SET FOREIGN_KEY_CHECKS = 1; +DELETE FROM post_migration_procedure_registry WHERE name = 'refresh_mvAllComponentsByServiceVulnerabilityCounts_proc'; diff --git a/internal/database/mariadb/test/fixture.go b/internal/database/mariadb/test/fixture.go index 4cf7f33fe..2d6d62c42 100644 --- a/internal/database/mariadb/test/fixture.go +++ b/internal/database/mariadb/test/fixture.go @@ -2121,7 +2121,6 @@ func (s *DatabaseSeeder) RefreshCountIssueRatings() error { func (s *DatabaseSeeder) RefreshComponentVulnerabilityCounts() error { _, err := s.db.Exec(` CALL refresh_mvSingleComponentByServiceVulnerabilityCounts_proc(); - CALL refresh_mvAllComponentsByServiceVulnerabilityCounts_proc(); `) return err @@ -2133,6 +2132,12 @@ func (s *DatabaseSeeder) RefreshMvVulnerabilityList() error { return err } +func (s *DatabaseSeeder) RefreshMvVulnerabilityService() error { + _, err := s.db.Exec(`CALL refresh_mvVulnerabilityService_proc();`) + + return err +} + func (s *DatabaseSeeder) RefreshMvComponentService() error { _, err := s.db.Exec(`CALL refresh_mvComponentService_proc();`) diff --git a/internal/e2e/image_query_test.go b/internal/e2e/image_query_test.go index c50f51277..8450dd165 100644 --- a/internal/e2e/image_query_test.go +++ b/internal/e2e/image_query_test.go @@ -522,6 +522,9 @@ func (it *imageTest) seed10Entries() { err = it.seeder.RefreshMvVulnerabilityList() Expect(err).To(BeNil()) + + err = it.seeder.RefreshMvVulnerabilityService() + Expect(err).To(BeNil()) } func (it *imageTest) seedTieBreakerData() { @@ -576,6 +579,12 @@ func (it *imageTest) seedTieBreakerData() { err = it.seeder.RefreshMvComponentService() Expect(err).To(BeNil()) + + err = it.seeder.RefreshMvVulnerabilityList() + Expect(err).To(BeNil()) + + err = it.seeder.RefreshMvVulnerabilityService() + Expect(err).To(BeNil()) } // seedServiceScopedVulnData seeds a controlled scenario to test that the detail view @@ -711,6 +720,9 @@ func (it *imageTest) seedServiceScopedVulnData() string { err = it.seeder.RefreshMvVulnerabilityList() Expect(err).To(BeNil()) + err = it.seeder.RefreshMvVulnerabilityService() + Expect(err).To(BeNil()) + return svcA.CCRN.String } @@ -773,6 +785,9 @@ func (it *imageTest) seedRatingMismatchData() string { err = it.seeder.RefreshMvVulnerabilityList() Expect(err).To(BeNil()) + err = it.seeder.RefreshMvVulnerabilityService() + Expect(err).To(BeNil()) + return svc.CCRN.String }