Skip to content
Open
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ require (
k8s.io/klog/v2 v2.130.1
k8s.io/kube-aggregator v0.34.1
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397
sigs.k8s.io/kustomize/kyaml v0.21.1
sigs.k8s.io/yaml v1.6.0
)

require (
Expand All @@ -41,6 +43,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-errors/errors v1.4.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
Expand All @@ -49,6 +52,7 @@ require (
github.com/google/cel-go v0.26.0 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
Expand Down Expand Up @@ -87,9 +91,9 @@ require (
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
sigs.k8s.io/controller-runtime v0.12.1 // indirect
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
sigs.k8s.io/kube-storage-version-migrator v0.0.6-0.20230721195810-5c8923c5ff96 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)

replace github.com/onsi/ginkgo/v2 => github.com/openshift/onsi-ginkgo/v2 v2.6.1-0.20241205171354-8006f302fd12
15 changes: 13 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
Expand All @@ -45,6 +47,8 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
Expand Down Expand Up @@ -103,6 +107,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.74.0 h1:AHzMWDxNiAVscJL6+4wkvFRTpMnJqiaZFEKA/osaBXE=
github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.74.0/go.mod h1:wAR5JopumPtAZnu0Cjv2PSqV4p4QB09LMhc6fZZTXuA=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
Expand All @@ -113,8 +119,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
Expand Down Expand Up @@ -211,6 +217,7 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Expand Down Expand Up @@ -240,6 +247,10 @@ sigs.k8s.io/controller-runtime v0.12.1 h1:4BJY01xe9zKQti8oRjj/NeHKRXthf1YkYJAgLO
sigs.k8s.io/controller-runtime v0.12.1/go.mod h1:BKhxlA4l7FPK4AQcsuL4X6vZeWnKDXez/vp1Y8dxTU0=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/kube-storage-version-migrator v0.0.6-0.20230721195810-5c8923c5ff96 h1:PFWFSkpArPNJxFX4ZKWAk9NSeRoZaXschn+ULa4xVek=
sigs.k8s.io/kube-storage-version-migrator v0.0.6-0.20230721195810-5c8923c5ff96/go.mod h1:EOBQyBowOUsd7U4CJnMHNE0ri+zCXyouGdLwC/jZU+I=
sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI=
sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
Expand Down
7 changes: 4 additions & 3 deletions hack/generate-lib-resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ def generate_resourcebuilder(directory, types, clients, modifiers, health_checks
client_properties['{}Client{}'.format(short_name, version)] = {
'package': package,
'client_short_name': client_short_name,
'type': '*{}.{}'.format(client_short_name, client['type']),
'type': '{}{}.{}'.format("" if 'interface' in client and client['interface'] == True else "*", client_short_name, client['type']),
'protobuf': client['package'].startswith('k8s.io/') and 'kube-aggregator' not in client['package'],
}

Expand Down Expand Up @@ -292,13 +292,13 @@ def scheme_group_versions(types):
}
clients = {
'github.com/openshift/api/security/v1': {'package': 'github.com/openshift/client-go/security/clientset/versioned/typed/security/v1', 'type': 'SecurityV1Client'},
'github.com/openshift/api/config/v1': {'package': 'github.com/openshift/client-go/config/clientset/versioned/typed/config/v1', 'type': 'ConfigV1Client'},
'github.com/openshift/api/config/v1': {'package': 'github.com/openshift/client-go/config/clientset/versioned/typed/config/v1', 'type': 'ConfigV1Interface', 'interface': True},
'github.com/openshift/api/image/v1': {'package': 'github.com/openshift/client-go/image/clientset/versioned/typed/image/v1', 'type': 'ImageV1Client'},
'github.com/operator-framework/api/pkg/operators/v1': {'package': 'github.com/operator-framework/operator-lifecycle-manager/pkg/api/client/clientset/versioned/typed/operators/v1', 'type': 'OperatorsV1Client'},
'k8s.io/api/admissionregistration/v1': {'package': 'k8s.io/client-go/kubernetes/typed/admissionregistration/v1', 'type': 'AdmissionregistrationV1Client'},
'k8s.io/api/apps/v1': {'package': 'k8s.io/client-go/kubernetes/typed/apps/v1', 'type': 'AppsV1Client'},
'k8s.io/api/batch/v1': {'package': 'k8s.io/client-go/kubernetes/typed/batch/v1', 'type': 'BatchV1Client'},
'k8s.io/api/core/v1': {'package': 'k8s.io/client-go/kubernetes/typed/core/v1', 'type': 'CoreV1Client'},
'k8s.io/api/core/v1': {'package': 'k8s.io/client-go/kubernetes/typed/core/v1', 'type': 'CoreV1Interface', 'interface': True},
'k8s.io/api/rbac/v1': {'package': 'k8s.io/client-go/kubernetes/typed/rbac/v1', 'type': 'RbacV1Client'},
'k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1': {'package': 'k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1', 'type': 'ApiextensionsV1Client'},
'k8s.io/kube-aggregator/pkg/apis/apiregistration/v1': {'package': 'k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/typed/apiregistration/v1', 'type': 'ApiregistrationV1Client'},
Expand All @@ -307,6 +307,7 @@ def scheme_group_versions(types):
modifiers = {
('k8s.io/api/apps/v1', 'Deployment'): 'b.modifyDeployment',
('k8s.io/api/apps/v1', 'DaemonSet'): 'b.modifyDaemonSet',
('k8s.io/api/core/v1', 'ConfigMap'): 'b.modifyConfigMap',
}

health_checks = {
Expand Down
249 changes: 249 additions & 0 deletions lib/resourcebuilder/core.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
package resourcebuilder

import (
"context"
"fmt"
"slices"
"sort"

"sigs.k8s.io/kustomize/kyaml/yaml"

configv1 "github.com/openshift/api/config/v1"
configclientv1 "github.com/openshift/client-go/config/clientset/versioned/typed/config/v1"
configlistersv1 "github.com/openshift/client-go/config/listers/config/v1"
"github.com/openshift/library-go/pkg/operator/configobserver/apiserver"
"github.com/openshift/library-go/pkg/operator/events"
"github.com/openshift/library-go/pkg/operator/resourcesynccontroller"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/client-go/tools/cache"
"k8s.io/klog/v2"
"k8s.io/utils/clock"
)

const (
// ConfigMapInjectTLSAnnotation is the annotation key that triggers TLS injection into ConfigMaps
ConfigMapInjectTLSAnnotation = "config.openshift.io/inject-tls"
)

func (b *builder) modifyConfigMap(ctx context.Context, cm *corev1.ConfigMap) error {
// Check for TLS injection annotation
if value, ok := cm.Annotations[ConfigMapInjectTLSAnnotation]; !ok || value != "true" {
return nil
}

klog.V(2).Infof("ConfigMap %s/%s has %s annotation set to true", cm.Namespace, cm.Name, ConfigMapInjectTLSAnnotation)

// Empty data, nothing to inject into
if cm.Data == nil {
klog.V(2).Infof("ConfigMap %s/%s has empty data, skipping", cm.Namespace, cm.Name)
return nil
}

// Observe TLS configuration from APIServer
minTLSVersion, minTLSFound, cipherSuites, ciphersFound, err := b.observeTLSConfiguration(ctx, cm)
if err != nil {
return fmt.Errorf("unable to observe TLS configuration: %v", err)
}
if !minTLSFound && !ciphersFound {
klog.V(2).Infof("ConfigMap %s/%s: no TLS configuration found, skipping", cm.Namespace, cm.Name)
return nil
}

klog.V(4).Infof("Observing minTLSVersion=%v, cipherSuites=%v", minTLSVersion, cipherSuites)

// Process each data entry that contains GenericOperatorConfig
for key, value := range cm.Data {
Copy link
Contributor

Choose a reason for hiding this comment

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

This was supposed to be a comment about the need to document this functionality in the enhancement repository. However, you already thought of this on Slack, awesome! I will have a look for a right page. I am leaving this comment for me to remember this.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think that we can create a new document in https://github.com/openshift/enhancements/tree/master/dev-guide/cluster-version-operator/dev, as this will not be an enhancement per se, but rather a dev-guide documentation explaining the usage and any constraints.

Copy link
Member Author

Choose a reason for hiding this comment

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

klog.V(4).Infof("Processing %q key", key)
// Parse YAML into RNode to preserve formatting and field order
rnode, err := yaml.Parse(value)
if err != nil {
klog.V(4).Infof("ConfigMap's %q entry parsing failed: %v", key, err)
// Not valid YAML, skip this entry
continue
}

// Check if this is a GenericOperatorConfig by checking the kind field
kind := rnode.GetKind()
if kind != "GenericOperatorConfig" {
klog.V(4).Infof("ConfigMap's %q entry is not a GenericOperatorConfig, skipping this entry", key)
continue
}

klog.V(2).Infof("ConfigMap %s/%s processing GenericOperatorConfig in key %s", cm.Namespace, cm.Name, key)

// Inject TLS settings into the GenericOperatorConfig while preserving structure
if err := updateRNodeWithTLSSettings(rnode, minTLSVersion, minTLSFound, cipherSuites, ciphersFound); err != nil {
klog.V(4).Infof("Error injecting the TLS configuration: %v", err)
return err
}

// Marshal the modified RNode back to YAML
modifiedYAML, err := rnode.String()
if err != nil {
klog.V(4).Infof("Error marshalling the modified ConfigMap back to YAML: %v", err)
return err
}

// Update the ConfigMap data entry with the modified YAML
cm.Data[key] = modifiedYAML
klog.V(2).Infof("ConfigMap %s/%s updated GenericOperatorConfig in key %s with %d ciphers and minTLSVersion=%s",
cm.Namespace, cm.Name, key, len(cipherSuites), minTLSVersion)
}

klog.V(2).Infof("APIServer config available for ConfigMap %s/%s TLS injection", cm.Namespace, cm.Name)

return nil
}

// observeTLSConfiguration retrieves TLS configuration from the APIServer cluster CR
// using ObserveTLSSecurityProfile and extracts minTLSVersion and cipherSuites.
// minTLSVersion string, minTLSFound bool, cipherSuites []string, ciphersFound bool, err error
func (b *builder) observeTLSConfiguration(ctx context.Context, cm *corev1.ConfigMap) (string, bool, []string, bool, error) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is something that was not apparent to me initially.

One note on error handling. The apiserver.ObserveTLSSecurityProfile function preserves the original configuration on actual errors. With the proposed changes, we are currently not able to do that.

For example, the modifyConfigMap will end immediately if !minTLSFound && !ciphersFound is true; however, this signals the CVO that the in-cluster ConfigMap is supposed to look like its manifest. Thus, when it reaches ApplyConfigMapv1, it will find out that the current object in the cluster differs from the desired, and it will update the ConfigMap to reflect the manifest, as we did not modify the manifest in-memory, and it differs from what is applied in the cluster.

This will result in a new different TLS configuration, rather than the existing one.

Copy link
Contributor

@DavidHurta DavidHurta Mar 3, 2026

Choose a reason for hiding this comment

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

I do not see a precedent in the modify functions to fetch the in-cluster object, but on actual errors, I think we will need to fetch it in this specific scenario.

Copy link
Member Author

Choose a reason for hiding this comment

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

That's true. Nice observation. Even though this is a corner case it can still happen. So what you are saying is to get the actual CM when !minTLSFound && !ciphersFound and preserve its current tls configuration?

To analyze the execution path:

  • So in innerTLSSecurityProfileObservations it could happen SetNestedField, resp. SetNestedStringSlice errors and only partial observedConfig is generated. E.g. when either of the SetNestedXXX functions changes its implementation. Which can be gated by a simple unit test (which we already have in the PR). So we are safe here.
  • Wrt. getSecurityProfileCiphers it always returns both MinTLSVersion and Ciphers set. So unless the APIServer CI is invalid, we are safe as well.
  • A user might configure a custom TLS profile and set {MinTLSVersion, Ciphers} partially (even empty if that's allowed)?.
  • The last case is for where the APIServer CI is not found. In which case there's always a default TLS configuration.

So !minTLSFound && !ciphersFound will happen only when user provides a custom TLS profile where none of the mentioned options are configured.

@DavidHurta please double check with me in case I omitted something.

Copy link
Member Author

@ingvagabund ingvagabund Mar 4, 2026

Choose a reason for hiding this comment

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

I am building a new unit test for the following valid APIServer CR:

spec:
  tlsSecurityProfile:
    type: Custom
    custom:
      tlsProfileSpec:
        ciphers: []
        minTLSVersion: ""

apiserver.ObserveTLSSecurityProfile returns minTLSFound=true, ciphersFound=true. The expected outcome is to set empty values in a CM.

Copy link
Member Author

Choose a reason for hiding this comment

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

I added more unit tests for cover the case where tlsProfileSpec is specified only partly (including both ciphers and minTLSVersions are empty).

Copy link
Contributor

@DavidHurta DavidHurta Mar 9, 2026

Choose a reason for hiding this comment

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

Apologies, I will respond to your comment tomorrow, it's getting late. However, I appreciate you taking the time to analyze the execution path.


My goal is also for us to refine our approach to error handling in the proposed feature and have a clear intention.

For example, a networking error happens when fetching the APIServer. What is the expected outcome, and is it in line with what's acceptable to respect the central TLS profile? For example, do we want to continue running the last valid configuration, or do we want to default to whatever we see fit?

For example, looking at the ConfigObserver implementation, which calls observe functions together with the apiserver.ObserveTLSSecurityProfile implementation. Errors are tolerated, but existing configurations are kept in such cases, if I read the code correctly.

Copy link
Member Author

Choose a reason for hiding this comment

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

a networking error happens when fetching the APIServer

Depending on whether the error is transient or not getting the current CM from the apiserver might fail as well.

looking at the ConfigObserver implementation, which calls observe functions together with

Nice find. This makes it even more free/important in what the defaults should be. Even when the merging of two observedConfigs fails the execution continues. Leaving up to each operator to bear the consequences of making the "right" decision.

Copy link
Member Author

Choose a reason for hiding this comment

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

For example, do we want to continue running the last valid configuration, or do we want to default to whatever we see fit?

The last valid configuration might not be always available. Also, temporarily setting defaults until the transient situation is resolved might cause blips. Not every client can accept the defaults. E.g. if the default minTLSVersion happens to be v1.3 but clients still need v1.2 (for whatever reason) setting these defaults might cause disruptions.

// Create a lister adapter for ObserveTLSSecurityProfile
lister := &apiServerListerAdapter{
client: b.configClientv1.APIServers(),
ctx: ctx,
}
listers := &configObserverListers{
apiServerLister: lister,
}

// Create an in-memory event recorder that doesn't send events to the API server
recorder := events.NewInMemoryRecorder("configmap-tls-injection", clock.RealClock{})

// Call ObserveTLSSecurityProfile to get TLS configuration
observedConfig, errs := apiserver.ObserveTLSSecurityProfile(listers, recorder, map[string]any{})
if len(errs) > 0 {
// Log errors but continue - ObserveTLSSecurityProfile is tolerant of missing config
for _, err := range errs {
klog.Errorf("ConfigMap %s/%s: error observing TLS profile: %v", cm.Namespace, cm.Name, err)
}
}

// Extract the TLS settings from the observed config
minTLSVersion, minTLSFound, err := unstructured.NestedString(observedConfig, "servingInfo", "minTLSVersion")
if err != nil {
// This error is unlikely to happen unless unstructured.NestedString is buggy.
// From unstructured.NestedString's description:
// "Returns false if value is not found and an error if not a string."
// The observedConfig's servingInfo.minTLSVersion is of a string type
return "", false, nil, false, err
}
cipherSuites, ciphersFound, _ := unstructured.NestedStringSlice(observedConfig, "servingInfo", "cipherSuites")
if err != nil {
// This error is unlikely to happen unless unstructured.NestedStringSlice is buggy
// From unstructured.NestedString's description:
// "Returns false if value is not found and an error if not a []interface{} or contains non-string items in the slice."
// The observedConfig's servingInfo.minTLSVersion is of a string type
return "", false, nil, false, err
}

// Sort cipher suites for consistent comparison
if ciphersFound && len(cipherSuites) > 0 {
sort.Strings(cipherSuites)
}

return minTLSVersion, minTLSFound, cipherSuites, ciphersFound, nil
}

// updateRNodeWithTLSSettings injects TLS settings into a GenericOperatorConfig RNode while preserving structure
// cipherSuites is expected to be sorted
func updateRNodeWithTLSSettings(rnode *yaml.RNode, minTLSVersion string, minTLSFound bool, cipherSuites []string, ciphersFound bool) error {
Copy link
Contributor

Choose a reason for hiding this comment

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

One thing we'll need to ensure is to be explicit about the state of the desired ConfigMap object. That is, if ciphers are not found, or the minimal TLS version is not found, we'll need to explicitly state the ConfigMap contents.

We work with the contents of a manifest file in modifyConfigMap. For example, the manifest file may theoretically already contain some ciphers from its component team. Doing nothing would leave these ciphers in place, resulting in not respecting the central TLS profile. We'll need to ensure that we remove existing such fields or set a default value.

Copy link
Member Author

Choose a reason for hiding this comment

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

I have not been able to find any documentation saying what's the expected outcome when only a partial custom TLS configuration is provided. The implementation itself in the library-go's innerTLSSecurityProfileObservations allows the following:

Empty custom tls profile configuration:

config: &configv1.TLSSecurityProfile{
				Type:   configv1.TLSProfileCustomType,
				Custom: &configv1.CustomTLSProfile{},
			},

getSecurityProfileCiphers yields empty ciphers and minTLSVersion

Only ciphers are set:

config: &configv1.TLSSecurityProfile{
				Type:   configv1.TLSProfileCustomType,
				Custom: &configv1.CustomTLSProfile{
					TLSProfileSpec: configv1.TLSProfileSpec{
						Ciphers: []string{
							"TLS_AES_128_GCM_SHA256",
						},
					},
				},
			},

getSecurityProfileCiphers yields non-empty ciphers and empty minTLSVersion (and vice verse).

When invoked through ObserveTLSSecurityProfile as the entry point the same is observed. I.e. missing minTLSVersion clears the same field in the provided existingConfig.

Yet, each operator can decide differently. I.e. to preserve or to clear. E.g. checking KSO implementation (other operators adopted the same pattern by copy-pasting) the rendered --tls-min-version, resp. --tls-ciphers-suites arguments are not set if the corresponding minTLSVersion, resp. ciphers are not present.

Copy link
Member Author

Choose a reason for hiding this comment

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

Just recently merged https://github.com/openshift/enhancements/blob/master/enhancements/security/centralized-tls-config.md proposal introduces a new tlsAdherence field (TechPreview for now) that solves the question of whether a CM is expected to respect the centralized TLS configuration or legacy (keep it opinionated). Which is not the same like the current question in hand about the defaults. Yet, it's good to keep in mind this mechanism.

servingInfo, err := rnode.Pipe(yaml.LookupCreate(yaml.MappingNode, "servingInfo"))
if err != nil {
return err
}

if ciphersFound {
currentCiphers, err := getSortedCipherSuites(servingInfo)
if err != nil {
return err
}
if !slices.Equal(currentCiphers, cipherSuites) {
// Create a sequence node with the cipher suites
seqNode := yaml.NewListRNode(cipherSuites...)
if err := servingInfo.PipeE(yaml.SetField("cipherSuites", seqNode)); err != nil {
return err
}
}
}

// Update minTLSVersion if found
if minTLSFound {
if err := servingInfo.PipeE(yaml.SetField("minTLSVersion", yaml.NewStringRNode(minTLSVersion))); err != nil {
return err
}
}

return nil
}

// getSortedCipherSuites extracts and sorts the cipherSuites string slice from a servingInfo RNode
func getSortedCipherSuites(servingInfo *yaml.RNode) ([]string, error) {
ciphersNode, err := servingInfo.Pipe(yaml.Lookup("cipherSuites"))
if err != nil || ciphersNode == nil {
return nil, err
}

elements, err := ciphersNode.Elements()
if err != nil {
return nil, err
}

var ciphers []string
for _, elem := range elements {
// For scalar nodes, access the value directly without YAML serialization
// This avoids the trailing newline that String() (which uses yaml.Encode) adds
if elem.YNode().Kind == yaml.ScalarNode {
value := elem.YNode().Value
// Skip empty values
if value == "" {
continue
}
ciphers = append(ciphers, value)
}
}

// Sort cipher suites for consistent comparison
sort.Strings(ciphers)

return ciphers, nil
}

// apiServerListerAdapter adapts a client interface to the lister interface
type apiServerListerAdapter struct {
client configclientv1.APIServerInterface
ctx context.Context
}

func (a *apiServerListerAdapter) List(selector labels.Selector) ([]*configv1.APIServer, error) {
// Not implemented - ObserveTLSSecurityProfile only uses Get()
return nil, nil
}

func (a *apiServerListerAdapter) Get(name string) (*configv1.APIServer, error) {
return a.client.Get(a.ctx, name, metav1.GetOptions{})
}

// configObserverListers implements the configobserver.Listers interface.
// It's expected to be used solely for apiserver.ObserveTLSSecurityProfile.
type configObserverListers struct {
apiServerLister configlistersv1.APIServerLister
}

func (l *configObserverListers) APIServerLister() configlistersv1.APIServerLister {
return l.apiServerLister
}

func (l *configObserverListers) ResourceSyncer() resourcesynccontroller.ResourceSyncer {
// Not needed for TLS observation
return nil
}

func (l *configObserverListers) PreRunHasSynced() []cache.InformerSynced {
// Not needed for TLS observation
return nil
}
Comment on lines +241 to +249
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's understand the repo structure and find the relevant files
git ls-files | grep -E "(resourcebuilder|configobserver)" | head -20

Repository: openshift/cluster-version-operator

Length of output: 1188


🏁 Script executed:

# Read the core.go file to see the full context
cat -n lib/resourcebuilder/core.go | sed -n '220,240p'

# Also check the interface definition
cat -n lib/resourcebuilder/interface.go | head -50

# Look at the observe_tlssecurityprofile implementation
cat -n vendor/github.com/openshift/library-go/pkg/operator/configobserver/apiserver/observe_tlssecurityprofile.go

Repository: openshift/cluster-version-operator

Length of output: 8005


🏁 Script executed:

# Find the APIServerLister interface definition
rg -n "type APIServerLister" --type go

# Search for all uses of ResourceSyncer in the codebase
rg -n "ResourceSyncer" --type go

# Search for all uses of PreRunHasSynced in the codebase
rg -n "PreRunHasSynced" --type go

# Find where configObserverListers is instantiated
rg -n "configObserverListers{" --type go

Repository: openshift/cluster-version-operator

Length of output: 2179


🏁 Script executed:

# Read the config_observer_controller.go around the lines that call PreRunHasSynced
cat -n vendor/github.com/openshift/library-go/pkg/operator/configobserver/config_observer_controller.go | sed -n '35,45p'

# Read the context around lines 255-257 where PreRunHasSynced is called
cat -n vendor/github.com/openshift/library-go/pkg/operator/configobserver/config_observer_controller.go | sed -n '250,270p'

# Look at the full Listers interface definition
cat -n vendor/github.com/openshift/library-go/pkg/operator/configobserver/config_observer_controller.go | sed -n '33,50p'

# Check the apiserver listers.go to understand the interface
cat -n vendor/github.com/openshift/library-go/pkg/operator/configobserver/apiserver/listers.go

Repository: openshift/cluster-version-operator

Length of output: 3078


🏁 Script executed:

# Find where listersToInformer is called
rg -n "listersToInformer" --type go

# Look for the Run method or startup code that uses this
cat -n vendor/github.com/openshift/library-go/pkg/operator/configobserver/config_observer_controller.go | sed -n '200,250p'

# Check how configObserverListers is initialized and used in lib/resourcebuilder/core.go
cat -n lib/resourcebuilder/core.go | sed -n '1,50p'

# Look for where configObserverListers is created/instantiated
rg -n "New.*configObserverListers\|&configObserverListers" --type go

Repository: openshift/cluster-version-operator

Length of output: 6154


🏁 Script executed:

# Look at the code around line 126 in config_observer_controller.go
cat -n vendor/github.com/openshift/library-go/pkg/operator/configobserver/config_observer_controller.go | sed -n '100,140p'

# Search for any calls to ResourceSyncer() in the codebase
rg -n "\.ResourceSyncer\(\)" --type go -A 2

# Check if the configObserverListers is actually used with the config observer controller
grep -r "configObserverListers\|NewConfigObserverListers" lib/ --include="*.go"

Repository: openshift/cluster-version-operator

Length of output: 2578


🏁 Script executed:

# Find where configObserverListers is used
rg -n "configObserverListers" lib/resourcebuilder/core.go -B 5 -A 10

# Check if it's passed to NewNestedConfigObserver or similar
rg -n "NewNestedConfigObserver\|NewConfigObserver" lib/ --include="*.go" -B 2 -A 2

Repository: openshift/cluster-version-operator

Length of output: 2026


🏁 Script executed:

# Double-check the current usage is safe by seeing what ObserveTLSSecurityProfile actually does
cat -n vendor/github.com/openshift/library-go/pkg/operator/configobserver/apiserver/observe_tlssecurityprofile.go | sed -n '35,80p'

Repository: openshift/cluster-version-operator

Length of output: 2460


Return proper implementations instead of nil for ResourceSyncer() and PreRunHasSynced(), or document that configObserverListers must never be passed to code paths that invoke these methods.

ObserveTLSSecurityProfile currently only calls APIServerLister() and never invokes ResourceSyncer() or PreRunHasSynced(), so the current usage is safe. However, configObserverListers implements the configobserver.Listers interface, which requires both methods. The listersToInformer() function in library-go's config_observer_controller.go (lines 255–257) iterates over the result of PreRunHasSynced() without nil-checking, which would panic if this lister is ever passed to NewNestedConfigObserver() or similar code paths. This pattern violates the interface contract and creates a landmine for future refactoring. Either return empty slices/objects instead of nil, or add a clear comment that this lister is only for TLS observation and must never be used with the config observer controller.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/resourcebuilder/core.go` around lines 224 - 232, configObserverListers
currently returns nil from ResourceSyncer() and PreRunHasSynced(), violating the
configobserver.Listers contract and risking a panic in callers like
listersToInformer() or NewNestedConfigObserver(); fix by returning concrete
no-op implementations: have PreRunHasSynced() return an empty slice (e.g. return
[]cache.InformerSynced{}), and implement a small noop ResourceSyncer type that
satisfies resourcesynccontroller.ResourceSyncer (methods are no-ops) and return
an instance from ResourceSyncer(); update core.go's configObserverListers
methods accordingly (referencing configObserverListers, ResourceSyncer(),
PreRunHasSynced(), ObserveTLSSecurityProfile, APIServerLister(),
listersToInformer(), NewNestedConfigObserver()) or, if you choose not to add a
noop, add a clear comment on configObserverListers' type header stating it must
never be passed to code paths that call these methods.

Loading