diff --git a/central/complianceoperator/v2/compliancemanager/manager_impl.go b/central/complianceoperator/v2/compliancemanager/manager_impl.go index 935ec0d8364fb..4b3e340f4eb5d 100644 --- a/central/complianceoperator/v2/compliancemanager/manager_impl.go +++ b/central/complianceoperator/v2/compliancemanager/manager_impl.go @@ -17,6 +17,7 @@ import ( "github.com/stackrox/rox/central/sensor/service/connection" "github.com/stackrox/rox/generated/internalapi/central" "github.com/stackrox/rox/generated/storage" + "github.com/stackrox/rox/pkg/env" "github.com/stackrox/rox/pkg/features" "github.com/stackrox/rox/pkg/logging" "github.com/stackrox/rox/pkg/protocompat" @@ -299,6 +300,15 @@ func (m *managerImpl) processRequestToSensor(ctx context.Context, scanRequest *s } } + // For scan configs that include tailored profiles, verify that each tailored profile has + // equivalent content (same equivalence_hash) on every selected cluster. + // The bypass env var disables this check entirely (name-only, same as non-tailored profile behavior). + if !env.SkipTailoredProfileEquivalenceHash.BooleanSetting() { + if err := m.validateTailoredProfilesEquivalence(ctx, returnedProfiles, clusters, scanRequest.GetScanConfigName()); err != nil { + return nil, err + } + } + // Build profile refs (name + kind) - needed to support tailored profiles, a different resource kind in CO. // Persist profile_refs so the startup sync path can forward correct kinds to Sensor on reconnect. scanRequest.ProfileRefs = internaltov2storage.ProfileV2ToScanConfigRefs(returnedProfiles) @@ -569,3 +579,54 @@ func convertSchedule(scanRequest *storage.ComplianceOperatorScanConfigurationV2) return cron, nil } + +// validateTailoredProfilesEquivalence verifies that each tailored profile in the scan +// request is equivalent on every selected cluster, by comparing their equivalence hashes. +// Non-tailored profiles are not checked. Called only when hash checking is not bypassed. +// +// Hash equivalence: all instances share the same hash value (COUNT(DISTINCT hash) = 1). +// All-empty is allowed — treated as equivalent, matching the profile picker semantics. +func (m *managerImpl) validateTailoredProfilesEquivalence( + ctx context.Context, + cluster0Profiles []*storage.ComplianceOperatorProfileV2, + clusters []string, + scanConfigName string, +) error { + var tailoredProfileNames []string + for _, p := range cluster0Profiles { + if p.GetOperatorKind() == storage.ComplianceOperatorProfileV2_TAILORED_PROFILE { + tailoredProfileNames = append(tailoredProfileNames, p.GetName()) + } + } + if len(tailoredProfileNames) == 0 { + return nil + } + + allInstances, err := m.profileDS.SearchProfiles(ctx, search.NewQueryBuilder(). + AddExactMatches(search.ClusterID, clusters...). + AddExactMatches(search.ComplianceOperatorProfileName, tailoredProfileNames...).ProtoQuery()) + if err != nil { + return errors.Wrapf(err, "scan configuration %q: failed to retrieve tailored profiles across clusters", scanConfigName) + } + + byName := make(map[string][]*storage.ComplianceOperatorProfileV2, len(tailoredProfileNames)) + for _, p := range allInstances { + byName[p.GetName()] = append(byName[p.GetName()], p) + } + + for _, name := range tailoredProfileNames { + instances := byName[name] + if len(instances) != len(clusters) { + log.Warnf("Scan configuration %q rejected: tailored profile %q is present on %d of %d selected clusters", + scanConfigName, name, len(instances), len(clusters)) + return errors.Errorf("scan configuration %q: tailored profile %q is not present on all selected clusters", scanConfigName, name) + } + if !profileDatastore.TailoredProfilesEquivalent(instances) { + log.Warnf("Scan configuration %q rejected: tailored profile %q has different content across clusters (equivalence hash mismatch). "+ + "Deploy an identical tailored profile on all clusters before creating a multi-cluster scan.", scanConfigName, name) + return errors.Errorf("scan configuration %q: tailored profile %q has different content across clusters; "+ + "ensure the same tailored profile is deployed on every cluster before creating a multi-cluster scan", scanConfigName, name) + } + } + return nil +} diff --git a/central/complianceoperator/v2/compliancemanager/manager_impl_test.go b/central/complianceoperator/v2/compliancemanager/manager_impl_test.go index db6c76cd7eb75..3473700201bbb 100644 --- a/central/complianceoperator/v2/compliancemanager/manager_impl_test.go +++ b/central/complianceoperator/v2/compliancemanager/manager_impl_test.go @@ -16,6 +16,7 @@ import ( sensorMocks "github.com/stackrox/rox/central/sensor/service/connection/mocks" "github.com/stackrox/rox/generated/internalapi/central" "github.com/stackrox/rox/generated/storage" + "github.com/stackrox/rox/pkg/env" "github.com/stackrox/rox/pkg/features" "github.com/stackrox/rox/pkg/fixtures/fixtureconsts" "github.com/stackrox/rox/pkg/sac" @@ -518,6 +519,10 @@ func (suite *complianceManagerTestSuite) TestProcessScanRequestSendsProfileRefsW getTestProfileWithKind("ocp4-cis", "1.0.0", "platform", "ocp4", testconsts.Cluster1, 1, storage.ComplianceOperatorProfileV2_PROFILE), getTestProfileWithKind("rhcos4-cis", "1.0.0", "node", "rhcos4", testconsts.Cluster1, 1, storage.ComplianceOperatorProfileV2_TAILORED_PROFILE), }, nil).Times(1) + // Second SearchProfiles: hash validation for the tailored profile across the selected cluster. + suite.profileDS.EXPECT().SearchProfiles(ctx, gomock.Any()).Return([]*storage.ComplianceOperatorProfileV2{ + getTestProfileWithKind("rhcos4-cis", "1.0.0", "node", "rhcos4", testconsts.Cluster1, 1, storage.ComplianceOperatorProfileV2_TAILORED_PROFILE), + }, nil).Times(1) suite.scanConfigDS.EXPECT().UpsertScanConfiguration(ctx, gomock.Any()).Return(nil).Times(1) suite.connectionMgr.EXPECT().SendMessage(testconsts.Cluster1, gomock.Any()).DoAndReturn( func(_ string, msg *central.MsgToSensor) error { @@ -923,3 +928,71 @@ func (suite *complianceManagerTestSuite) TestRemoveObsoleteResultsByProfiles() { }) } } + +// TestTailoredProfileHashValidation verifies that multi-cluster scan configs with tailored +// profiles are rejected when the profile content (equivalence_hash) differs across clusters. +func (suite *complianceManagerTestSuite) TestTailoredProfileHashValidation() { + ctx := suite.testContexts[testutils.UnrestrictedReadWriteCtx] + req := getTestRecWithClustersAndProfiles("", []string{testconsts.Cluster1, testconsts.Cluster2}, []string{"my-tp"}) + + tpCluster1 := getTestProfileWithKind("my-tp", "1.0.0", "platform", "ocp4", testconsts.Cluster1, 1, storage.ComplianceOperatorProfileV2_TAILORED_PROFILE) + tpCluster2SameHash := getTestProfileWithKind("my-tp", "1.0.0", "platform", "ocp4", testconsts.Cluster2, 1, storage.ComplianceOperatorProfileV2_TAILORED_PROFILE) + tpCluster2DiffHash := getTestProfileWithKind("my-tp", "1.0.0", "platform", "ocp4", testconsts.Cluster2, 1, storage.ComplianceOperatorProfileV2_TAILORED_PROFILE) + tpCluster1.EquivalenceHash = "hash-abc" + tpCluster2SameHash.EquivalenceHash = "hash-abc" + tpCluster2DiffHash.EquivalenceHash = "hash-xyz" + + suite.T().Run("TPs with same hash can be scheduled", func(t *testing.T) { + suite.scanConfigDS.EXPECT().GetScanConfigurationByName(ctx, mockScanName).Return(nil, nil).Times(1) + suite.scanConfigDS.EXPECT().ScanConfigurationProfileExists(ctx, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).Times(1) + // First SearchProfiles: cluster 0 only + suite.profileDS.EXPECT().SearchProfiles(ctx, gomock.Any()).Return([]*storage.ComplianceOperatorProfileV2{tpCluster1}, nil).Times(1) + // Second SearchProfiles: all clusters for hash validation + suite.profileDS.EXPECT().SearchProfiles(ctx, gomock.Any()).Return([]*storage.ComplianceOperatorProfileV2{tpCluster1, tpCluster2SameHash}, nil).Times(1) + suite.scanConfigDS.EXPECT().UpsertScanConfiguration(ctx, gomock.Any()).Return(nil).Times(1) + suite.connectionMgr.EXPECT().SendMessage(gomock.Any(), gomock.Any()).Return(nil).Times(2) + suite.clusterDatastore.EXPECT().GetClusterName(gomock.Any(), gomock.Any()).Return("test_cluster", true, nil).Times(2) + suite.scanConfigDS.EXPECT().UpdateClusterStatus(ctx, gomock.Any(), gomock.Any(), "", "test_cluster").Times(2) + + config, err := suite.manager.ProcessScanRequest(ctx, req, []string{testconsts.Cluster1, testconsts.Cluster2}) + suite.Require().NoError(err) + suite.Require().NotNil(config) + // Restore scan request ID so it can be reused. + req.Id = "" + }) + + suite.T().Run("TPs with different hash are rejected", func(t *testing.T) { + suite.scanConfigDS.EXPECT().GetScanConfigurationByName(ctx, mockScanName).Return(nil, nil).Times(1) + suite.scanConfigDS.EXPECT().ScanConfigurationProfileExists(ctx, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).Times(1) + // First SearchProfiles: cluster 0 only + suite.profileDS.EXPECT().SearchProfiles(ctx, gomock.Any()).Return([]*storage.ComplianceOperatorProfileV2{tpCluster1}, nil).Times(1) + // Second SearchProfiles: returns divergent hashes + suite.profileDS.EXPECT().SearchProfiles(ctx, gomock.Any()).Return([]*storage.ComplianceOperatorProfileV2{tpCluster1, tpCluster2DiffHash}, nil).Times(1) + // No Upsert or SendMessage expected — we fail before that. + + req.Id = "" + config, err := suite.manager.ProcessScanRequest(ctx, req, []string{testconsts.Cluster1, testconsts.Cluster2}) + suite.Require().Error(err) + suite.Require().Nil(config) + suite.Require().ErrorContains(err, "different content across clusters") + req.Id = "" + }) + + suite.T().Run("bypass env skips hash validation for multi-cluster tailored profile", func(t *testing.T) { + suite.T().Setenv(env.SkipTailoredProfileEquivalenceHash.EnvVar(), "true") + + reqBypass := getTestRecWithClustersAndProfiles("", []string{testconsts.Cluster1, testconsts.Cluster2}, []string{"my-tp"}) + suite.scanConfigDS.EXPECT().GetScanConfigurationByName(ctx, mockScanName).Return(nil, nil).Times(1) + suite.scanConfigDS.EXPECT().ScanConfigurationProfileExists(ctx, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).Times(1) + // First SearchProfiles only — second (hash validation) call skipped due to bypass. + suite.profileDS.EXPECT().SearchProfiles(ctx, gomock.Any()).Return([]*storage.ComplianceOperatorProfileV2{tpCluster1}, nil).Times(1) + suite.scanConfigDS.EXPECT().UpsertScanConfiguration(ctx, gomock.Any()).Return(nil).Times(1) + suite.connectionMgr.EXPECT().SendMessage(gomock.Any(), gomock.Any()).Return(nil).Times(2) + suite.clusterDatastore.EXPECT().GetClusterName(gomock.Any(), gomock.Any()).Return("test_cluster", true, nil).Times(2) + suite.scanConfigDS.EXPECT().UpdateClusterStatus(ctx, gomock.Any(), gomock.Any(), "", "test_cluster").Times(2) + + config, err := suite.manager.ProcessScanRequest(ctx, reqBypass, []string{testconsts.Cluster1, testconsts.Cluster2}) + suite.Require().NoError(err) + suite.Require().NotNil(config) + }) +} diff --git a/central/complianceoperator/v2/profiles/datastore/datastore_classify_test.go b/central/complianceoperator/v2/profiles/datastore/datastore_classify_test.go new file mode 100644 index 0000000000000..d716b4be76ffc --- /dev/null +++ b/central/complianceoperator/v2/profiles/datastore/datastore_classify_test.go @@ -0,0 +1,169 @@ +package datastore + +import ( + "testing" + + "github.com/stackrox/rox/generated/storage" + "github.com/stretchr/testify/assert" +) + +func makeProfile(name, clusterID string, kind storage.ComplianceOperatorProfileV2_OperatorKind, hash string) *storage.ComplianceOperatorProfileV2 { + return &storage.ComplianceOperatorProfileV2{ + Name: name, + ClusterId: clusterID, + OperatorKind: kind, + EquivalenceHash: hash, + } +} + +const ( + standard = storage.ComplianceOperatorProfileV2_PROFILE + tailored = storage.ComplianceOperatorProfileV2_TAILORED_PROFILE +) + +// buildByName is a test helper that groups a profile list the same way filterNonEquivalentTPs does. +func buildByName(profiles []*storage.ComplianceOperatorProfileV2) map[string][]*storage.ComplianceOperatorProfileV2 { + byName := make(map[string][]*storage.ComplianceOperatorProfileV2) + for _, p := range profiles { + byName[p.GetName()] = append(byName[p.GetName()], p) + } + return byName +} + +func TestApplyEquivalenceFilter_OOBPassesThrough(t *testing.T) { + profiles := []*storage.ComplianceOperatorProfileV2{ + makeProfile("ocp4-cis", "c1", standard, ""), + makeProfile("ocp4-cis", "c2", standard, ""), + } + names := []string{"ocp4-cis"} + got := applyEquivalenceFilter(names, buildByName(profiles)) + assert.Equal(t, []string{"ocp4-cis"}, got) +} + +func TestApplyEquivalenceFilter_TPSameHashPassesThrough(t *testing.T) { + profiles := []*storage.ComplianceOperatorProfileV2{ + makeProfile("my-tp", "c1", tailored, "hash-abc"), + makeProfile("my-tp", "c2", tailored, "hash-abc"), + } + names := []string{"my-tp"} + got := applyEquivalenceFilter(names, buildByName(profiles)) + assert.Equal(t, []string{"my-tp"}, got) +} + +func TestApplyEquivalenceFilter_TPDifferentHashExcluded(t *testing.T) { + profiles := []*storage.ComplianceOperatorProfileV2{ + makeProfile("my-tp", "c1", tailored, "hash-abc"), + makeProfile("my-tp", "c2", tailored, "hash-xyz"), + } + names := []string{"my-tp"} + got := applyEquivalenceFilter(names, buildByName(profiles)) + assert.Empty(t, got) +} + +func TestApplyEquivalenceFilter_AllEmptyHashEquivalent(t *testing.T) { + profiles := []*storage.ComplianceOperatorProfileV2{ + makeProfile("my-tp", "c1", tailored, ""), + makeProfile("my-tp", "c2", tailored, ""), + } + names := []string{"my-tp"} + got := applyEquivalenceFilter(names, buildByName(profiles)) + assert.Equal(t, []string{"my-tp"}, got) +} + +func TestApplyEquivalenceFilter_PreservesOrder(t *testing.T) { + profiles := []*storage.ComplianceOperatorProfileV2{ + makeProfile("tp-a", "c1", tailored, "h"), + makeProfile("tp-a", "c2", tailored, "h"), + makeProfile("tp-bad", "c1", tailored, "h1"), + makeProfile("tp-bad", "c2", tailored, "h2"), + makeProfile("ocp4-cis", "c1", standard, ""), + makeProfile("ocp4-cis", "c2", standard, ""), + } + names := []string{"tp-a", "tp-bad", "ocp4-cis"} + got := applyEquivalenceFilter(names, buildByName(profiles)) + assert.Equal(t, []string{"tp-a", "ocp4-cis"}, got) +} + +func TestApplyEquivalenceFilter_EmptyInput(t *testing.T) { + got := applyEquivalenceFilter(nil, nil) + assert.Nil(t, got) +} + +func TestTailoredProfilesEquivalent(t *testing.T) { + tests := []struct { + name string + instances []*storage.ComplianceOperatorProfileV2 + want bool + }{ + { + name: "nil slice", + instances: nil, + want: true, + }, + { + name: "empty slice", + instances: []*storage.ComplianceOperatorProfileV2{}, + want: true, + }, + { + name: "single instance", + instances: []*storage.ComplianceOperatorProfileV2{makeProfile("x", "c1", tailored, "abc")}, + want: true, + }, + { + name: "same hash across clusters", + instances: []*storage.ComplianceOperatorProfileV2{ + makeProfile("x", "c1", tailored, "abc"), + makeProfile("x", "c2", tailored, "abc"), + }, + want: true, + }, + { + name: "all-empty hash treated as equivalent", + instances: []*storage.ComplianceOperatorProfileV2{ + makeProfile("x", "c1", tailored, ""), + makeProfile("x", "c2", tailored, ""), + }, + want: true, + }, + { + name: "different hashes", + instances: []*storage.ComplianceOperatorProfileV2{ + makeProfile("x", "c1", tailored, "abc"), + makeProfile("x", "c2", tailored, "xyz"), + }, + want: false, + }, + { + name: "one empty one non-empty hash", + instances: []*storage.ComplianceOperatorProfileV2{ + makeProfile("x", "c1", tailored, "abc"), + makeProfile("x", "c2", tailored, ""), + }, + want: false, + }, + { + name: "three clusters, all same hash", + instances: []*storage.ComplianceOperatorProfileV2{ + makeProfile("x", "c1", tailored, "h"), + makeProfile("x", "c2", tailored, "h"), + makeProfile("x", "c3", tailored, "h"), + }, + want: true, + }, + { + name: "three clusters, last differs", + instances: []*storage.ComplianceOperatorProfileV2{ + makeProfile("x", "c1", tailored, "h"), + makeProfile("x", "c2", tailored, "h"), + makeProfile("x", "c3", tailored, "z"), + }, + want: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, TailoredProfilesEquivalent(tc.instances)) + }) + } +} diff --git a/central/complianceoperator/v2/profiles/datastore/datastore_impl.go b/central/complianceoperator/v2/profiles/datastore/datastore_impl.go index eb15e5c8ddd58..e819f5a2f72b2 100644 --- a/central/complianceoperator/v2/profiles/datastore/datastore_impl.go +++ b/central/complianceoperator/v2/profiles/datastore/datastore_impl.go @@ -7,6 +7,8 @@ import ( v1 "github.com/stackrox/rox/generated/api/v1" "github.com/stackrox/rox/generated/storage" "github.com/stackrox/rox/pkg/auth/permissions" + "github.com/stackrox/rox/pkg/env" + "github.com/stackrox/rox/pkg/logging" pgPkg "github.com/stackrox/rox/pkg/postgres" "github.com/stackrox/rox/pkg/postgres/schema" "github.com/stackrox/rox/pkg/sac" @@ -17,6 +19,7 @@ import ( ) var ( + log = logging.LoggerForModule() complianceSAC = sac.ForResource(resources.Compliance) ) @@ -119,19 +122,67 @@ func (d *datastoreImpl) GetProfilesNames(ctx context.Context, q *v1.Query, clust } parsedQuery.Pagination = q.GetPagination() - var profileNames []string - err = pgSearch.RunSelectRequestForSchemaFn[distinctProfileName](ctx, d.db, schema.ComplianceOperatorProfileV2Schema, parsedQuery, func(r *distinctProfileName) error { - profileNames = append(profileNames, r.ProfileName) - return nil - }) + var results []*distinctProfileName + results, err = pgSearch.RunSelectRequestForSchema[distinctProfileName](ctx, d.db, schema.ComplianceOperatorProfileV2Schema, parsedQuery) if err != nil { return nil, err } - if len(profileNames) == 0 { + if len(results) == 0 { return nil, nil } + profileNames := make([]string, 0, len(results)) + for _, result := range results { + profileNames = append(profileNames, result.ProfileName) + } + + if !env.SkipTailoredProfileEquivalenceHash.BooleanSetting() { + profileNames, err = d.filterNonEquivalentTPs(ctx, profileNames, readableClusterIDs) + if err != nil { + return nil, err + } + } + + return profileNames, nil +} + +// filterNonEquivalentTPs removes tailored profile names whose instances have differing +// equivalence hashes across clusters. OOB profiles are passed through unchanged. +func (d *datastoreImpl) filterNonEquivalentTPs(ctx context.Context, names []string, clusterIDs []string) ([]string, error) { + if len(names) == 0 { + return names, nil + } + + profiles, err := d.store.GetByQuery(ctx, search.NewQueryBuilder(). + AddExactMatches(search.ClusterID, clusterIDs...). + AddExactMatches(search.ComplianceOperatorProfileName, names...).ProtoQuery()) + if err != nil { + return nil, err + } + + byName := make(map[string][]*storage.ComplianceOperatorProfileV2, len(names)) + for _, p := range profiles { + byName[p.GetName()] = append(byName[p.GetName()], p) + } + + return applyEquivalenceFilter(names, byName), nil +} - return profileNames, err +// applyEquivalenceFilter filters names using pre-fetched profile instances grouped by name. +// Tailored profiles whose instances have inconsistent equivalence hashes are removed. +// OOB profiles are passed through unchanged. +func applyEquivalenceFilter(names []string, byName map[string][]*storage.ComplianceOperatorProfileV2) []string { + filtered := names[:0] + for _, name := range names { + instances := byName[name] + isTP := len(instances) > 0 && instances[0].GetOperatorKind() == storage.ComplianceOperatorProfileV2_TAILORED_PROFILE + if !isTP || TailoredProfilesEquivalent(instances) { + filtered = append(filtered, name) + } else { + log.Warnf("Tailored profile %q excluded from profile picker: content differs across clusters (equivalence hash mismatch). "+ + "Deploy an identical tailored profile on all clusters to make it schedulable. Alternatively, enable ROX_COMPLIANCE_SKIP_TAILORED_PROFILE_EQUIVALENCE_HASH to skip equivalence checks.", name) + } + } + return filtered } type distinctProfileCount struct { @@ -139,33 +190,35 @@ type distinctProfileCount struct { Name string `db:"compliance_profile_name"` } -// CountDistinctProfiles returns count of distinct profiles matching query +// CountDistinctProfiles returns the number of distinct profile names present on all requested clusters. func (d *datastoreImpl) CountDistinctProfiles(ctx context.Context, q *v1.Query, clusterIDs []string) (int, error) { - // Build the matching query to restrict profiles to the incoming clusters readableClusterIDs := bestEffortClusters(ctx, clusterIDs) + var err error + q, err = withSACFilter(ctx, resources.Compliance, q) + if err != nil { + return 0, err + } + query := search.ConjunctionQuery( search.NewQueryBuilder(). AddExactMatches(search.ClusterID, readableClusterIDs...). - AddNumericField(search.ProfileCount, storage.Comparator_EQUALS, float32(len(readableClusterIDs))).ProtoQuery(), + AddNumericField(search.ProfileCount, storage.Comparator_EQUALS, float32(len(readableClusterIDs))). + ProtoQuery(), q, ) - query.GroupBy = &v1.QueryGroupBy{ Fields: []string{ search.ComplianceOperatorProfileName.String(), }, } - var count int - err := pgSearch.RunSelectRequestForSchemaFn[distinctProfileCount](ctx, d.db, schema.ComplianceOperatorProfileV2Schema, withCountQuery(query, search.ComplianceOperatorProfileName), func(r *distinctProfileCount) error { - count++ - return nil - }) + var results []*distinctProfileCount + results, err = pgSearch.RunSelectRequestForSchema[distinctProfileCount](ctx, d.db, schema.ComplianceOperatorProfileV2Schema, withCountQuery(query, search.ComplianceOperatorProfileName)) if err != nil { return 0, err } - return count, nil + return len(results), nil } func withCountQuery(query *v1.Query, field search.FieldLabel) *v1.Query { @@ -176,6 +229,22 @@ func withCountQuery(query *v1.Query, field search.FieldLabel) *v1.Query { return cloned } +// TailoredProfilesEquivalent returns true when all instances share the same equivalence_hash +// value. An all-empty hash is treated as equivalent (COUNT(DISTINCT "") = 1). An empty or nil +// slice is considered equivalent. +func TailoredProfilesEquivalent(instances []*storage.ComplianceOperatorProfileV2) bool { + if len(instances) == 0 { + return true + } + h := instances[0].GetEquivalenceHash() + for _, inst := range instances[1:] { + if inst.GetEquivalenceHash() != h { + return false + } + } + return true +} + func bestEffortClusters(ctx context.Context, clusterIDs []string) []string { // Best effort SAC. We only want to return profiles from the cluster list that the user has access to // view. So we perform an access check to create a narrowed list instead of embedding it in the query, diff --git a/pkg/env/compliance_operator.go b/pkg/env/compliance_operator.go index 3fc8e5e15cf0e..5bb7aa9b76648 100644 --- a/pkg/env/compliance_operator.go +++ b/pkg/env/compliance_operator.go @@ -30,6 +30,13 @@ var ( // This value can be customized via the ROX_COMPLIANCE_MINIMAL_SUPPORTED_OPERATOR_VERSION environment variable. // If the environment variable is unset, contains an invalid version, or is lower than the default value, the default value "v1.6.0" will be used. ComplianceMinimalSupportedVersion = RegisterVersionSetting("ROX_COMPLIANCE_MINIMAL_SUPPORTED_OPERATOR_VERSION", "v1.6.0", "v1.6.0") + // SkipTailoredProfileEquivalenceHash disables hash-based equivalence for tailored profiles when true. + // When enabled, tailored profiles are treated like non-tailored profiles: name presence on all clusters + // is sufficient for listing and scan config creation, regardless of content differences across clusters. + // Use as a break-glass escape hatch; logs a warning at Central startup. + // Default off — hash-based equivalence is active. + SkipTailoredProfileEquivalenceHash = RegisterBooleanSetting("ROX_COMPLIANCE_SKIP_TAILORED_PROFILE_EQUIVALENCE_HASH", false) + // ComplianceScansRunningInParallelMetricObservationPeriod defines an observation window for the compliance operator metrics in Central. // For example, a metric output like this: // rox_central_complianceoperator_num_scans_running_in_parallel_bucket{le="0"} 0