Skip to content
Merged
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
76 changes: 62 additions & 14 deletions pkg/compare/compare.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,16 @@ type Options struct {
ShowManagedFields bool
OutputFormat string

builder *resource.Builder
correlator *MultiCorrelator[ReferenceTemplate]
metricsTracker *MetricsTracker
templates []ReferenceTemplate
local bool
types []string
ref Reference
userConfig UserConfig
Concurrency int
builder *resource.Builder
correlator *MultiCorrelator[ReferenceTemplate]
metricsTracker *MetricsTracker
templates []ReferenceTemplate
matchedByReferenceOnly []ReferenceTemplate
local bool
types []string
ref Reference
userConfig UserConfig
Concurrency int

userOverridesPath string
userOverridesCorrelator Correlator[*UserOverride]
Expand Down Expand Up @@ -298,7 +299,6 @@ func (o *Options) GetRefFS() (fs.FS, error) {
}
return os.DirFS(rootPath), nil
}

func (o *Options) Complete(f kcmdutil.Factory, cmd *cobra.Command, args []string) error {
var err error
o.builder = f.NewBuilder()
Expand Down Expand Up @@ -702,6 +702,43 @@ func diffAgainstTemplate(temp ReferenceTemplate, clusterCR *unstructured.Unstruc
return res, nil
}

// buildWarnings constructs warnings array from summary data
func buildWarnings(sum *Summary) []Warning {
warnings := []Warning{}

if len(sum.MatchedByReferenceOnly) > 0 {
warnings = append(warnings, Warning{
Type: "InferredResourcesNotValidated",
Message: "Resource(s) found via ownerReferences or RBAC subjects but contents not validated",
Resources: sum.MatchedByReferenceOnly,
})
}

return warnings
}

// checkTemplateReferences proactively checks templates that exist in ownerReferences or RBAC subjects
func (o *Options) checkTemplateReferences(ownerRefCorrelator *OwnerReferenceCorrelator[ReferenceTemplate], subjectsCorrelator *SubjectsCorrelator[ReferenceTemplate]) {
for _, template := range o.templates {
templateMd := template.GetMetadata()

// Check ownerReferences
if _, err := ownerRefCorrelator.Match(templateMd); err == nil {
o.metricsTracker.addMatch(template)
o.matchedByReferenceOnly = append(o.matchedByReferenceOnly, template)
klog.V(1).Infof("Template %s found via ownerReferences (content not validated)", template.GetIdentifier())
continue
}

// Check RBAC subjects
if _, err := subjectsCorrelator.Match(templateMd); err == nil {
o.metricsTracker.addMatch(template)
o.matchedByReferenceOnly = append(o.matchedByReferenceOnly, template)
klog.V(1).Infof("Template %s found via RBAC subjects (content not validated)", template.GetIdentifier())
}
}
}

// Run uses the factory to parse file arguments (in case of local mode) or gather all cluster resources matching
// templates types. For each Resource it finds the matching Resource template and
// injects, compares, and runs against differ.
Expand Down Expand Up @@ -762,6 +799,17 @@ func (o *Options) Run() error {
// Load all CRs for the lookup function:
AllCRs = clusterCRs

// Add OwnerReferenceCorrelator now that AllCRs is populated
ownerRefCorrelator := NewOwnerReferenceCorrelator(o.templates, AllCRs)
o.correlator.AddCorrelator(ownerRefCorrelator)

// Add SubjectsCorrelator for RBAC subjects
subjectsCorrelator := NewSubjectsCorrelator(o.templates, AllCRs)
o.correlator.AddCorrelator(subjectsCorrelator)

// Proactively check templates that exist in ownerReferences or subjects and mark them as matched
o.checkTemplateReferences(ownerRefCorrelator, subjectsCorrelator)

process := func(clusterCR *unstructured.Unstructured) error {
temps, err := o.correlator.Match(clusterCR)
if err != nil && (!containOnly(err, []error{UnknownMatch{}}) || o.diffAll) {
Expand All @@ -782,9 +830,8 @@ func (o *Options) Run() error {
if errors.As(err, &nomatch) {
klog.V(1).Infof("Skipping comparison of %s: doNotMatch returned by all templates", apiKindNamespaceName(clusterCR))
return nil
} else {
o.metricsTracker.addUNMatch(clusterCR)
}
o.metricsTracker.addUNMatch(clusterCR)
return err
}

Expand Down Expand Up @@ -833,9 +880,10 @@ func (o *Options) Run() error {
return fmt.Errorf("error occurred while trying to process resources: %w", err)
}

sum := newSummary(o.ref, o.metricsTracker, numDiffCRs, o.templates, numPatched)
sum := newSummary(o.ref, o.metricsTracker, numDiffCRs, o.templates, numPatched, o.matchedByReferenceOnly)

_, err = Output{Summary: sum, Diffs: &diffs, patches: o.newUserOverrides}.Print(o.OutputFormat, o.Out, o.verboseOutput)
warnings := buildWarnings(sum)
_, err = Output{Summary: sum, Diffs: &diffs, Warnings: warnings, patches: o.newUserOverrides}.Print(o.OutputFormat, o.Out, o.verboseOutput)
if err != nil {
return err
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/compare/compare_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,8 @@ func TestCompareRun(t *testing.T) {
withSubTestSuffix("Filter unnamed template matches").
withMetadataFile("metadata-filter.yaml").
withChecks(defaultChecks.withPrefixedSuffix("Filter")),
defaultTest("OwnerReferencesMatch"),
defaultTest("RBACSubjectsMatch"),
}

tf := cmdtesting.NewTestFactory()
Expand Down
178 changes: 178 additions & 0 deletions pkg/compare/correlator.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ func NewMultiCorrelator[T CorrelationEntry](correlators []Correlator[T]) *MultiC
return &MultiCorrelator[T]{correlators: correlators}
}

func (c *MultiCorrelator[T]) AddCorrelator(correlator Correlator[T]) {
c.correlators = append(c.correlators, correlator)
}

func (c MultiCorrelator[T]) Match(object *unstructured.Unstructured) ([]T, error) {
var errs []error
for _, core := range c.correlators {
Expand Down Expand Up @@ -312,3 +316,177 @@ func (f FieldCorrelator[T]) Match(object *unstructured.Unstructured) ([]T, error
}
return objs, nil
}

// OwnerReferenceCorrelator searches for resources by checking if the resource appears in the ownerReferences
// field of any cluster resource. This handles cases where resources are managed by operators and don't exist
// as standalone objects but are referenced as owners.
type OwnerReferenceCorrelator[T CorrelationEntry] struct {
templates []T
ownerRefIndex map[string]bool // Index of all ownerReferences found (key = apiVersion_kind_namespace_name)
templatesMap map[string][]T // Map from apiVersion_kind_namespace_name to templates
}

// NewOwnerReferenceCorrelator creates a new OwnerReferenceCorrelator
// It builds an index of all ownerReferences found in cluster resources
func NewOwnerReferenceCorrelator[T CorrelationEntry](templates []T, clusterCRs []*unstructured.Unstructured) *OwnerReferenceCorrelator[T] {
templatesMap := make(map[string][]T)
for _, temp := range templates {
md := temp.GetMetadata()
key := apiKindNamespaceName(md)
templatesMap[key] = append(templatesMap[key], temp)
}

// Build index of all ownerReferences found in cluster resources
ownerRefIndex := make(map[string]bool)
for _, clusterCR := range clusterCRs {
ownerRefs, found, err := unstructured.NestedSlice(clusterCR.Object, "metadata", "ownerReferences")
if err != nil || !found {
continue
}

for _, ownerRefInterface := range ownerRefs {
ownerRef, ok := ownerRefInterface.(map[string]interface{})
if !ok {
continue
}

apiVersion, _, _ := unstructured.NestedString(ownerRef, "apiVersion")
kind, _, _ := unstructured.NestedString(ownerRef, "kind")
name, _, _ := unstructured.NestedString(ownerRef, "name")

if apiVersion == "" || kind == "" || name == "" {
continue
}

// Build the owner key
// For cluster-scoped owners referenced from namespaced resources, we need to index both:
// 1. Without namespace (for cluster-scoped owners)
// 2. With namespace (for namespaced owners)
ownerKeyWithoutNS := strings.Join([]string{apiVersion, kind, name}, FieldSeparator)
ownerRefIndex[ownerKeyWithoutNS] = true

if clusterCR.GetNamespace() != "" {
ownerKeyWithNS := strings.Join([]string{apiVersion, kind, clusterCR.GetNamespace(), name}, FieldSeparator)
ownerRefIndex[ownerKeyWithNS] = true
}
}
}

return &OwnerReferenceCorrelator[T]{
templates: templates,
ownerRefIndex: ownerRefIndex,
templatesMap: templatesMap,
}
}

// Match searches for the resource in the pre-built ownerReferences index
func (c OwnerReferenceCorrelator[T]) Match(object *unstructured.Unstructured) ([]T, error) {
searchKey := apiKindNamespaceName(object)

// Check if this resource exists as an ownerReference
if c.ownerRefIndex[searchKey] {
// Found the resource in ownerReferences index
if temps, ok := c.templatesMap[searchKey]; ok {
klog.V(1).Infof("Found resource %s via ownerReferences", searchKey)
return temps, nil
}
}

return []T{}, UnknownMatch{Resource: object}
}

// SubjectsCorrelator searches for resources by checking if the resource appears in the subjects
// field of RBAC resources (ClusterRoleBindings, RoleBindings). This handles cases where ServiceAccounts
// and other subjects are referenced but don't exist as standalone objects in the collection.
type SubjectsCorrelator[T CorrelationEntry] struct {
templates []T
subjectsIndex map[string]bool // Index of all subjects found (key = apiVersion_kind_namespace_name)
templatesMap map[string][]T // Map from apiVersion_kind_namespace_name to templates
}

// NewSubjectsCorrelator creates a new SubjectsCorrelator
// It builds an index of all subjects found in RBAC resources
func NewSubjectsCorrelator[T CorrelationEntry](templates []T, clusterCRs []*unstructured.Unstructured) *SubjectsCorrelator[T] {
templatesMap := make(map[string][]T)
for _, temp := range templates {
md := temp.GetMetadata()
key := apiKindNamespaceName(md)
templatesMap[key] = append(templatesMap[key], temp)
}

// Build index of all subjects found in cluster resources
subjectsIndex := make(map[string]bool)
for _, clusterCR := range clusterCRs {
// Only check RBAC resources (ClusterRoleBinding, RoleBinding)
kind := clusterCR.GetKind()
if kind != "ClusterRoleBinding" && kind != "RoleBinding" {
continue
}

subjects, found, err := unstructured.NestedSlice(clusterCR.Object, "subjects")
if err != nil || !found {
continue
}

for _, subjectInterface := range subjects {
subject, ok := subjectInterface.(map[string]interface{})
if !ok {
continue
}

// Extract subject information
// Note: subjects use "kind" not "apiVersion"
subjKind, _, _ := unstructured.NestedString(subject, "kind")
subjName, _, _ := unstructured.NestedString(subject, "name")
subjNamespace, _, _ := unstructured.NestedString(subject, "namespace")

if subjKind == "" || subjName == "" {
continue
}

// Build the subject key
// ServiceAccount subjects use "v1" as apiVersion
var apiVersion string
switch subjKind {
case "ServiceAccount":
apiVersion = "v1"
case "User", "Group":
// Users and Groups don't have apiVersions in the same way
continue
default:
continue
}

// Index both with and without namespace
subjKeyWithoutNS := strings.Join([]string{apiVersion, subjKind, subjName}, FieldSeparator)
subjectsIndex[subjKeyWithoutNS] = true

if subjNamespace != "" {
subjKeyWithNS := strings.Join([]string{apiVersion, subjKind, subjNamespace, subjName}, FieldSeparator)
subjectsIndex[subjKeyWithNS] = true
}
}
}

return &SubjectsCorrelator[T]{
templates: templates,
subjectsIndex: subjectsIndex,
templatesMap: templatesMap,
}
}

// Match searches for the resource in the pre-built subjects index
func (c SubjectsCorrelator[T]) Match(object *unstructured.Unstructured) ([]T, error) {
searchKey := apiKindNamespaceName(object)

// Check if this resource exists as a subject
if c.subjectsIndex[searchKey] {
// Found the resource in subjects index
if temps, ok := c.templatesMap[searchKey]; ok {
klog.V(1).Infof("Found resource %s via RBAC subjects", searchKey)
return temps, nil
}
}

return []T{}, UnknownMatch{Resource: object}
}
41 changes: 30 additions & 11 deletions pkg/compare/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ import (
"sigs.k8s.io/yaml"
)

// Warning represents a warning message in the output
type Warning struct {
Type string `json:"type"`
Message string `json:"message"`
Resources []string `json:"resources"`
}

// DiffSum Contains the diff output and correlation info of a specific CR
type DiffSum struct {
DiffOutput string `json:"DiffOutput"`
Expand Down Expand Up @@ -67,22 +74,26 @@ func (s DiffSum) WasPatched() bool {

// Summary Contains all info included in the Summary output of the compare command
type Summary struct {
ValidationIssues map[string]map[string]ValidationIssue `json:"ValidationIssuses"`
NumMissing int `json:"NumMissing"`
UnmatchedCRS []string `json:"UnmatchedCRS"`
NumDiffCRs int `json:"NumDiffCRs"`
TotalCRs int `json:"TotalCRs"`
MetadataHash string `json:"MetadataHash"`
PatchedCRs int `json:"patchedCRs"`
ValidationIssues map[string]map[string]ValidationIssue `json:"ValidationIssuses"`
NumMissing int `json:"NumMissing"`
UnmatchedCRS []string `json:"UnmatchedCRS"`
NumDiffCRs int `json:"NumDiffCRs"`
TotalCRs int `json:"TotalCRs"`
MetadataHash string `json:"MetadataHash"`
PatchedCRs int `json:"patchedCRs"`
MatchedByReferenceOnly []string `json:"matchedByReferenceOnly,omitempty"`
}

func newSummary(reference Reference, c *MetricsTracker, numDiffCRs int, templates []ReferenceTemplate, numPatchedCRs int) *Summary {
func newSummary(reference Reference, c *MetricsTracker, numDiffCRs int, templates []ReferenceTemplate, numPatchedCRs int, matchedByReferenceOnly []ReferenceTemplate) *Summary {
s := Summary{NumDiffCRs: numDiffCRs, PatchedCRs: numPatchedCRs}
s.ValidationIssues, s.NumMissing = reference.GetValidationIssues(c.MatchedTemplatesNames)
s.TotalCRs = c.getTotalCRs()
s.UnmatchedCRS = lo.Map(c.UnMatchedCRs, func(r *unstructured.Unstructured, i int) string {
return apiKindNamespaceName(r)
})
s.MatchedByReferenceOnly = lo.Map(matchedByReferenceOnly, func(t ReferenceTemplate, i int) string {
return t.GetIdentifier()
})

hash := sha256.New()

Expand Down Expand Up @@ -127,6 +138,13 @@ CRs in reference missing from the cluster: {{.NumMissing}}
{{- else}}
No validation issues with the cluster
{{- end }}
{{- if ne (len .MatchedByReferenceOnly) 0 }}

Warning: {{len .MatchedByReferenceOnly}} resource(s) found via ownerReferences or RBAC subjects but contents not validated:
{{- range $cr := .MatchedByReferenceOnly }}
- {{ $cr }}
{{- end }}
{{- end }}
{{- if ne (len .UnmatchedCRS) 0 }}
Cluster CRs unmatched to reference CRs: {{len .UnmatchedCRS}}
{{ toYaml .UnmatchedCRS}}
Expand All @@ -148,9 +166,10 @@ No patched CRs

// Output Contains the complete output of the command
type Output struct {
Summary *Summary `json:"Summary"`
Diffs *[]DiffSum `json:"Diffs"`
patches []*UserOverride
Summary *Summary `json:"Summary"`
Diffs *[]DiffSum `json:"Diffs"`
Warnings []Warning `json:"Warnings,omitempty"`
patches []*UserOverride
}

func (o Output) String(showEmptyDiffs bool) string {
Expand Down
Loading