Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 77 additions & 20 deletions sensor/common/compliance/node_inventory_handler_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package compliance

import (
"context"
"fmt"
"strconv"
"strings"

"github.com/pkg/errors"
"github.com/quay/claircore/indexer/controller"
Expand Down Expand Up @@ -31,7 +33,11 @@ var (
const (
rhcosFullName = "Red Hat Enterprise Linux CoreOS"

// Golden image repository for RHCOS < 4.19 (OCP-style versions like 418.94.xxx)
goldenKey = rhcc.RepositoryKey

// RHEL CPE repository for RHCOS 4.19+ (RHEL-style versions like 9.6.xxx)
rhelCPEKey = "rhel-cpe-repository"
)

var (
Expand Down Expand Up @@ -419,12 +425,12 @@ func (c *nodeInventoryHandlerImpl) sendNodeIndex(toC chan<- *message.ExpiringMes
return
}

isRHCOS, version, err := c.nodeRHCOSMatcher.GetRHCOSVersion(indexWrap.NodeName)
isRHCOS, ocpVersion, version, err := c.nodeRHCOSMatcher.GetRHCOSVersion(indexWrap.NodeName)
if err != nil {
log.Warnf("Unable to determine RHCOS version for node %q: %v", indexWrap.NodeName, err)
isRHCOS = false
}
log.Debugf("Node=%q discovered RHCOS=%t rhcos-version=%q", indexWrap.NodeName, isRHCOS, version)
log.Debugf("Node=%q discovered RHCOS=%t ocp-version=%q rhcos-version=%q", indexWrap.NodeName, isRHCOS, ocpVersion, version)

select {
case <-c.stopper.Flow().StopRequested():
Expand All @@ -441,7 +447,7 @@ func (c *nodeInventoryHandlerImpl) sendNodeIndex(toC chan<- *message.ExpiringMes
arch = extractArch(indexWrap.IndexReport)
c.archCache[indexWrap.NodeName] = arch
}
log.Debugf("Attaching OCI entry for 'rhcos' to index-report for node %s: version=%s, arch=%s", indexWrap.NodeName, version, arch)
log.Debugf("Attaching OCI entry for 'rhcos' to index-report for node %s: ocp-version=%s, version=%s, arch=%s", indexWrap.NodeName, ocpVersion, version, arch)
irWrapperFunc = attachRPMtoRHCOS
}
toC <- message.New(&central.MsgFromSensor{
Expand All @@ -452,7 +458,7 @@ func (c *nodeInventoryHandlerImpl) sendNodeIndex(toC chan<- *message.ExpiringMes
// This can be changed to CREATE or UPDATE for Sensor 4.8 or when Central 4.6 is out of support.
Action: central.ResourceAction_UNSET_ACTION_RESOURCE,
Resource: &central.SensorEvent_IndexReport{
IndexReport: irWrapperFunc(version, arch, indexWrap.IndexReport),
IndexReport: irWrapperFunc(ocpVersion, version, arch, indexWrap.IndexReport),
},
},
},
Expand All @@ -472,7 +478,37 @@ func normalizeVersion(version string) []int32 {
return []int32{v[0], v[1], 0, 0, 0, 0, 0, 0, 0, 0}
}

func noop(_, _ string, rpm *v4.IndexReport) *v4.IndexReport {
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (complexity): Consider simplifying the new RHCOS handling by narrowing the wrapper function signatures and centralizing version and repository selection logic into focused helpers.

You can preserve the new behavior while significantly reducing complexity by tightening the function signatures and consolidating the version/repo logic.

1. Drop ocpVersion from the wrapper function type

ocpVersion is only used for logging in sendNodeIndex and is not consulted in buildRHCOSIndexReport. Passing it through multiple layers just to satisfy a function type increases cognitive load.

You can keep GetRHCOSVersion as-is (it may be useful elsewhere) but narrow the wrapper function to exactly what’s needed:

// Narrowed wrapper type: only pass what is actually needed downstream.
type indexWrapperFunc func(version, arch string, rpm *v4.IndexReport) *v4.IndexReport

func noop(_ , _ string, rpm *v4.IndexReport) *v4.IndexReport {
	return rpm
}

func (c *nodeInventoryHandlerImpl) sendNodeIndex(toC chan<- *message.ExpiringMessage, indexWrap *index.IndexReportWrap) {
	// ...
	isRHCOS, ocpVersion, version, err := c.nodeRHCOSMatcher.GetRHCOSVersion(indexWrap.NodeName)
	// keep ocpVersion for logging only
	log.Debugf("Node=%q discovered RHCOS=%t ocp-version=%q rhcos-version=%q", indexWrap.NodeName, isRHCOS, ocpVersion, version)

	irWrapperFunc := indexWrapperFunc(noop)
	arch := c.archCache[indexWrap.NodeName]
	if isRHCOS {
		// ...
		irWrapperFunc = attachRPMtoRHCOS
	}

	toC <- message.New(&central.MsgFromSensor{
		Msg: &central.MsgFromSensor_Event{
			Event: &central.SensorEvent{
				// ...
				Resource: &central.SensorEvent_IndexReport{
					IndexReport: irWrapperFunc(version, arch, indexWrap.IndexReport),
				},
			},
		},
	})
}

And adjust attachRPMtoRHCOS accordingly:

func attachRPMtoRHCOS(version, arch string, rpm *v4.IndexReport) *v4.IndexReport {
	idCandidate := 600
	// ... unchanged ...
	strID := strconv.Itoa(idCandidate)
	oci := buildRHCOSIndexReport(strID, version, arch)
	// ... unchanged ...
	return oci
}

This removes unused parameters from noop and attachRPMtoRHCOS while keeping the same behavior (ocpVersion is still logged).

2. Collapse the version-schema helpers into one utility

isNewRHCOSVersionSchema and extractRHELMajorVersion both parse the same version string. You can do this once and return both the scheme and the major:

type rhcosVersionScheme struct {
	RHELMajor string
	IsRHEL    bool
}

// parseRHCOSVersionSchema parses the version once and indicates whether it is RHEL-style.
func parseRHCOSVersionSchema(version string) rhcosVersionScheme {
	parts := strings.SplitN(version, ".", 2)
	if len(parts) < 2 {
		return rhcosVersionScheme{RHELMajor: "9", IsRHEL: false}
	}

	majorStr := parts[0]
	majorInt, err := strconv.Atoi(majorStr)
	if err != nil {
		return rhcosVersionScheme{RHELMajor: "9", IsRHEL: false}
	}

	// RHEL: 8.x, 9.x, 10.x; OCP-style: 412.x, 418.x, ...
	if majorInt < 100 {
		return rhcosVersionScheme{RHELMajor: majorStr, IsRHEL: true}
	}

	return rhcosVersionScheme{RHELMajor: "9", IsRHEL: false}
}

Then buildRHCOSIndexReport only calls this once.

3. Extract a repo config helper to remove duplication in buildRHCOSIndexReport

Right now buildRHCOSIndexReport sets repoKey, repoName, repoURI, repoCPE with a default and overrides them conditionally, then uses the same values multiple times. A small struct plus a helper makes the function shorter and clearer:

type rhcosRepoConfig struct {
	Name string
	Key  string
	URI  string
	CPE  string
}

func determineRHCOSRepo(version string) rhcosRepoConfig {
	scheme := parseRHCOSVersionSchema(version)

	// Default: golden image repo
	cfg := rhcosRepoConfig{
		Name: goldenName,
		Key:  goldenKey,
		URI:  goldenURI,
		CPE:  "cpe:2.3:*",
	}

	if scheme.IsRHEL {
		// RHCOS 4.19+ RHEL-style versions
		rhelMajor := scheme.RHELMajor
		cfg.Key = rhelCPEKey
		cfg.Name = fmt.Sprintf("cpe:/o:redhat:enterprise_linux:%s::baseos", rhelMajor)
		cfg.URI = ""
		cfg.CPE = fmt.Sprintf("cpe:2.3:o:redhat:enterprise_linux:%s:*:baseos:*:*:*:*:*", rhelMajor)
		log.Debugf("RHCOS 4.19+ detected (version=%s): using RHEL %s CPE repository for vulnerability matching", version, rhelMajor)
	}

	return cfg
}

Then buildRHCOSIndexReport becomes:

func buildRHCOSIndexReport(id, version, arch string) *v4.IndexReport {
	repo := determineRHCOSRepo(version)

	return &v4.IndexReport{
		// ...
		Contents: &v4.Contents{
			Packages: map[string]*v4.Package{
				id: {
					// ...
					Source: &v4.Package{
						Id:      id,
						Name:    "rhcos",
						Kind:    "source",
						Version: version,
						Cpe:     repo.CPE,
					},
					Arch: arch,
					Cpe:  repo.CPE,
				},
			},
			PackagesDEPRECATED: []*v4.Package{
				{
					// same fields, use repo.CPE
				},
			},
			Repositories: map[string]*v4.Repository{
				id: {
					Id:   id,
					Name: repo.Name,
					Key:  repo.Key,
					Uri:  repo.URI,
					Cpe:  repo.CPE,
				},
			},
			RepositoriesDEPRECATED: []*v4.Repository{
				{
					Id:   id,
					Name: repo.Name,
					Key:  repo.Key,
					Uri:  repo.URI,
					Cpe:  repo.CPE,
				},
			},
			// ...
		},
	}
}

This keeps all the new behavior (including the RHEL CPE selection and logging) but:

  • Removes unused parameters from noop and attachRPMtoRHCOS.
  • Centralizes version parsing into one helper.
  • Encapsulates repo selection and removes duplicated assignment.

// extractRHELMajorVersion extracts the major version from a RHEL-style version string.
// For example, "9.6.20260217-1" returns "9".
func extractRHELMajorVersion(version string) string {
parts := strings.SplitN(version, ".", 2)
if len(parts) >= 1 {
return parts[0]
}
return "9" // Default to RHEL 9 if parsing fails
}

// isNewRHCOSVersionSchema returns true if the RHCOS version follows the RHEL-style
// (9.x, 10.x) rather than the OCP-style (4xx.x).
// RHCOS 4.19+ reports versions like "9.6.20260217-1" instead of "418.94.202501011408".
func isNewRHCOSVersionSchema(version string) bool {
Comment on lines +491 to +494
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion: Align isNewRHCOSVersionSchema comment with the actual heuristic

The logic currently classifies any version with major < 100 as using the new schema, but the comment frames this specifically as RHEL (8/9/10) vs OCP 4xx. That’s a broader heuristic than the comment implies. Please either (a) tighten the check to the expected ranges (e.g., known RHEL/OCP majors) or (b) update the comment to clearly document the actual behavior ("major < 100" = RHEL-style, >= 100 = OCP-style).

Suggested change
// isNewRHCOSVersionSchema returns true if the RHCOS version follows the RHEL-style
// (9.x, 10.x) rather than the OCP-style (4xx.x).
// RHCOS 4.19+ reports versions like "9.6.20260217-1" instead of "418.94.202501011408".
func isNewRHCOSVersionSchema(version string) bool {
// isNewRHCOSVersionSchema returns true if the RHCOS version is interpreted as
// "RHEL-style" (major < 100, e.g. 8.x, 9.x, 10.x) rather than "OCP-style"
// (major >= 100, e.g. 412.x, 418.x).
// RHCOS 4.19+ reports versions like "9.6.20260217-1" instead of "418.94.202501011408".
func isNewRHCOSVersionSchema(version string) bool {

if len(version) == 0 {
return false
}
parts := strings.SplitN(version, ".", 2)
if len(parts) < 2 {
return false
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return false
}
// RHEL versions are 8.x, 9.x, 10.x (single/double digit major)
// OCP-derived versions are 412.x, 413.x, 418.x (triple digit major)
return major < 100
}

func noop(_, _, _ string, rpm *v4.IndexReport) *v4.IndexReport {
return rpm
}

Expand Down Expand Up @@ -506,7 +542,7 @@ func extractArch(rpm *v4.IndexReport) string {
return ""
}

func attachRPMtoRHCOS(version, arch string, rpm *v4.IndexReport) *v4.IndexReport {
func attachRPMtoRHCOS(ocpVersion, version, arch string, rpm *v4.IndexReport) *v4.IndexReport {
idCandidate := 600 // Arbitrary selected. RHCOS has usually 520-560 rpm packages.
envs := rpm.GetContents().GetEnvironments()
if len(envs) == 0 {
Expand All @@ -516,7 +552,7 @@ func attachRPMtoRHCOS(version, arch string, rpm *v4.IndexReport) *v4.IndexReport
idCandidate++
}
strID := strconv.Itoa(idCandidate)
oci := buildRHCOSIndexReport(strID, version, arch)
oci := buildRHCOSIndexReport(strID, ocpVersion, version, arch)
for pkgID, pkg := range rpm.GetContents().GetPackages() {
oci.Contents.Packages[pkgID] = pkg
}
Expand All @@ -536,7 +572,28 @@ func attachRPMtoRHCOS(version, arch string, rpm *v4.IndexReport) *v4.IndexReport
return oci
}

func buildRHCOSIndexReport(Id, version, arch string) *v4.IndexReport {
// buildRHCOSIndexReport creates an IndexReport for the synthetic "rhcos" package.
// For RHCOS 4.19+ (new schema with RHEL-style versions like "9.6.xxx"), it uses
// the RHEL CPE repository instead of the golden image to enable proper vulnerability
// matching against RHEL-based CVE data.
func buildRHCOSIndexReport(Id, ocpVersion, version, arch string) *v4.IndexReport {
// Determine repository configuration based on version schema
repoKey := goldenKey
repoName := goldenName
repoURI := goldenURI
repoCPE := "cpe:2.3:*"

if isNewRHCOSVersionSchema(version) {
// RHCOS 4.19+ uses RHEL-style versions (e.g., "9.6.xxx")
// Use RHEL CPE repository for proper vulnerability matching
rhelMajor := extractRHELMajorVersion(version)
repoKey = rhelCPEKey
repoName = fmt.Sprintf("cpe:/o:redhat:enterprise_linux:%s::baseos", rhelMajor)
repoURI = "" // RHEL CPE repos don't have a URI
repoCPE = fmt.Sprintf("cpe:2.3:o:redhat:enterprise_linux:%s:*:baseos:*:*:*:*:*", rhelMajor)
log.Debugf("RHCOS 4.19+ detected (version=%s): using RHEL %s CPE repository for vulnerability matching", version, rhelMajor)
}

return &v4.IndexReport{
// This hashId is arbitrary. The value doesn't play a role for matcher, but must be valid sha256.
HashId: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Expand All @@ -559,10 +616,10 @@ func buildRHCOSIndexReport(Id, version, arch string) *v4.IndexReport {
Name: "rhcos",
Kind: "source",
Version: version,
Cpe: "cpe:2.3:*", // required to pass validation of scanner V4 API
Cpe: repoCPE,
},
Arch: arch,
Cpe: "cpe:2.3:*", // required to pass validation of scanner V4 API
Cpe: repoCPE,
},
},
PackagesDEPRECATED: []*v4.Package{
Expand All @@ -580,28 +637,28 @@ func buildRHCOSIndexReport(Id, version, arch string) *v4.IndexReport {
Name: "rhcos",
Kind: "source",
Version: version,
Cpe: "cpe:2.3:*", // required to pass validation of scanner V4 API
Cpe: repoCPE,
},
Arch: arch,
Cpe: "cpe:2.3:*", // required to pass validation of scanner V4 API
Cpe: repoCPE,
},
},
Repositories: map[string]*v4.Repository{
Id: {
Id: Id,
Name: goldenName,
Key: goldenKey,
Uri: goldenURI,
Cpe: "cpe:2.3:*", // required to pass validation of scanner V4 API
Name: repoName,
Key: repoKey,
Uri: repoURI,
Cpe: repoCPE,
},
},
RepositoriesDEPRECATED: []*v4.Repository{
{
Id: Id,
Name: goldenName,
Key: goldenKey,
Uri: goldenURI,
Cpe: "cpe:2.3:*", // required to pass validation of scanner V4 API
Name: repoName,
Key: repoKey,
Uri: repoURI,
Cpe: repoCPE,
},
},
// Environments must be present for the matcher to discover records
Expand Down
144 changes: 139 additions & 5 deletions sensor/common/compliance/node_inventory_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,109 @@
}
}

func (s *NodeInventoryHandlerTestSuite) TestIsNewRHCOSVersionSchema() {
cases := map[string]struct {
version string
expected bool
}{
"empty string": {
version: "",
expected: false,
},
"old schema - 4.17 derived": {
version: "417.94.202501011408",
expected: false,
},
"old schema - 4.18 derived": {
version: "418.94.202501011408-0",
expected: false,
},
"old schema - 4.12 derived": {
version: "412.86.202212081411-0",
expected: false,
},
"new schema - RHEL 9.6 based": {
version: "9.6.20260204",
expected: true,
},
"new schema - RHEL 9.6 with suffix": {
version: "9.6.20260217-1",
expected: true,
},
"new schema - RHEL 8.x based": {
version: "8.10.20250115",
expected: true,
},
"new schema - RHEL 10.x based": {
version: "10.0.20260115",
expected: true,
},
"malformed - no dots": {
version: "94176",
expected: false,
},
"malformed - non-numeric": {
version: "abc.def.xyz",
expected: false,
},
}
for name, tc := range cases {
s.Run(name, func() {
got := isNewRHCOSVersionSchema(tc.version)
s.Equal(tc.expected, got, "isNewRHCOSVersionSchema(%q) = %v, want %v", tc.version, got, tc.expected)
})
}
}

func (s *NodeInventoryHandlerTestSuite) TestBuildRHCOSIndexReportCPERepository() {
cases := map[string]struct {
ocpVersion string
rhcosVersion string
expectedVersion string
expectedRepoKey string
expectedRepoCPE string
}{
"old schema - uses golden image": {
ocpVersion: "4.17",
rhcosVersion: "417.94.202501071621-0",
expectedVersion: "417.94.202501071621-0",
expectedRepoKey: goldenKey,
expectedRepoCPE: "cpe:2.3:*",
},
"new schema RHEL 9 - uses RHEL CPE": {
ocpVersion: "4.21",
rhcosVersion: "9.6.20260217-1",
expectedVersion: "9.6.20260217-1",
expectedRepoKey: "rhel-cpe-repository",
expectedRepoCPE: "cpe:2.3:o:redhat:enterprise_linux:9:*:baseos:*:*:*:*:*",
},
"new schema RHEL 10 - uses RHEL CPE": {
ocpVersion: "4.25",
rhcosVersion: "10.0.20270115",
expectedVersion: "10.0.20270115",
expectedRepoKey: "rhel-cpe-repository",
expectedRepoCPE: "cpe:2.3:o:redhat:enterprise_linux:10:*:baseos:*:*:*:*:*",
},
}
for name, tc := range cases {
s.Run(name, func() {
got := buildRHCOSIndexReport("1", tc.ocpVersion, tc.rhcosVersion, "x86_64")
rhcosPkg := got.GetContents().GetPackages()["1"]
s.Require().NotNil(rhcosPkg, "rhcos package should exist")
s.Equal(tc.expectedVersion, rhcosPkg.GetVersion(), "rhcos package version should match expected")

repo := got.GetContents().GetRepositories()["1"]
s.Require().NotNil(repo, "repository should exist")
s.Equal(tc.expectedRepoKey, repo.GetKey(), "repository key should match expected")
s.Equal(tc.expectedRepoCPE, repo.GetCpe(), "repository CPE should match expected")
})
}
}

func (s *NodeInventoryHandlerTestSuite) TestAttachRPMtoRHCOS() {
arch := "x86_64"
rpmIR := fakeNodeIndex(arch)
got := attachRPMtoRHCOS("417.94.202501071621-0", arch, rpmIR)
got := attachRPMtoRHCOS("4.17", "417.94.202501071621-0", arch, rpmIR)

s.Lenf(got.GetContents().GetPackages(), len(rpmIR.GetContents().GetPackages())+1, "IR should have 1 extra package")
s.Lenf(got.GetContents().GetEnvironments(), len(rpmIR.GetContents().GetEnvironments())+1, "IR should have 1 extra envinronment")
Expand Down Expand Up @@ -214,6 +313,33 @@
s.Equal(goldenURI, rhcosRepo.GetUri())
}

func (s *NodeInventoryHandlerTestSuite) TestAttachRPMtoRHCOS_NewSchema() {
arch := "x86_64"
rpmIR := fakeNodeIndex(arch)
// Test with RHCOS 4.21 which uses RHEL-style versioning (9.6.xxx)
got := attachRPMtoRHCOS("4.21", "9.6.20260217-1", arch, rpmIR)

var rhcosPKG *v4.Package
for _, p := range got.GetContents().GetPackages() {
if p.GetName() == "rhcos" {
rhcosPKG = p
break
}
}
s.Require().NotNil(rhcosPKG, "the 'rhcos' pkg should exist in node index")
s.Equal("rhcos", rhcosPKG.GetName())
// For new schema, version should NOT be prefixed - it uses RHEL CPE repository instead
s.Equal("9.6.20260217-1", rhcosPKG.GetVersion())
// CPE should be RHEL 9 based
s.Equal("cpe:2.3:o:redhat:enterprise_linux:9:*:baseos:*:*:*:*:*", rhcosPKG.GetCpe())

// Verify the RHCOS repository uses RHEL CPE (ID "600" is the RHCOS package/repo ID)
rhcosRepo := got.GetContents().GetRepositories()["600"]
s.Require().NotNil(rhcosRepo, "RHCOS repository should exist")
s.Equal("rhel-cpe-repository", rhcosRepo.GetKey())
s.Equal("cpe:/o:redhat:enterprise_linux:9::baseos", rhcosRepo.GetName())
}

func (s *NodeInventoryHandlerTestSuite) TestCapabilities() {
inventories := make(chan *storage.NodeInventory)
defer close(inventories)
Expand Down Expand Up @@ -876,10 +1002,18 @@
return "", errors.New("cannot find node")
}

// mockNeverHitNodeIDMatcher simulates inability to find a node when GetNodeResource is called
// mockRHCOSNodeMatcher simulates RHCOS version detection
type mockRHCOSNodeMatcher struct{}

// GetRHCOSVersion always identifies as RHCOS and provides a valid version
func (c *mockRHCOSNodeMatcher) GetRHCOSVersion(_ string) (bool, string, error) {
return true, "417.94.202412120651-0", nil
// GetRHCOSVersion always identifies as RHCOS and provides a valid OCP-style version
func (c *mockRHCOSNodeMatcher) GetRHCOSVersion(_ string) (bool, string, string, error) {
return true, "4.17", "417.94.202412120651-0", nil
}

// mockRHCOSNewSchemaNodeMatcher simulates RHCOS 4.19+ version detection with RHEL-style version
type mockRHCOSNewSchemaNodeMatcher struct{}

Check failure on line 1014 in sensor/common/compliance/node_inventory_handler_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

type mockRHCOSNewSchemaNodeMatcher is unused (unused)

// GetRHCOSVersion identifies as RHCOS 4.19+ and provides a RHEL-style version
func (c *mockRHCOSNewSchemaNodeMatcher) GetRHCOSVersion(_ string) (bool, string, string, error) {

Check failure on line 1017 in sensor/common/compliance/node_inventory_handler_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

func (*mockRHCOSNewSchemaNodeMatcher).GetRHCOSVersion is unused (unused)
return true, "4.21", "9.6.20260217-1", nil
}
Loading
Loading