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 @@ -33,6 +33,8 @@ require (
k8s.io/klog/v2 v2.130.1
k8s.io/kube-aggregator v0.34.1
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4
sigs.k8s.io/kustomize/kyaml v0.21.1
sigs.k8s.io/yaml v1.6.0
)

require (
Expand All @@ -43,6 +45,7 @@ require (
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // 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.22.1 // indirect
github.com/go-openapi/jsonreference v0.21.2 // indirect
github.com/go-openapi/swag v0.25.1 // indirect
Expand All @@ -63,6 +66,7 @@ require (
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
Expand Down Expand Up @@ -101,9 +105,9 @@ require (
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
sigs.k8s.io/controller-runtime v0.22.2 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // 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
13 changes: 11 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
Expand Down Expand Up @@ -70,6 +72,8 @@ 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/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
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/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=
Expand Down Expand Up @@ -135,8 +139,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 @@ -229,6 +233,7 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
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 @@ -258,6 +263,10 @@ sigs.k8s.io/controller-runtime v0.22.2 h1:cK2l8BGWsSWkXz09tcS4rJh95iOLney5eawcK5
sigs.k8s.io/controller-runtime v0.22.2/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/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
1 change: 1 addition & 0 deletions hack/generate-lib-resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
250 changes: 250 additions & 0 deletions lib/resourcebuilder/core.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
package resourcebuilder

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

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

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"

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"
)

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" {

Choose a reason for hiding this comment

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

It will be necessary to also support v1.GenericControllerConfig as GenericOperatorConfig "deprecated" (it only exists on v1alpha1).

cc @DavidHurta

Copy link
Contributor

Choose a reason for hiding this comment

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

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

Choose a reason for hiding this comment

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

I am thinking about the possible options and implementations.

I am not opposed to the defaulting approach on errors, given we explicitly document this behaviour, and the current components, for which the feature is being implemented, are okay with this. However, it would result in the mentioned issues.

One approach could also be to firmly error out on any issue, but it has the potential to break things. The logic is part of the CVO trying to apply a payload. Thus, if logic were brittle or prone to errors, this could hinder the CVO's ability to reconcile existing payload or to upgrade the cluster. Thus, there is a need for the logic to be somewhat resilient. Although quick transient errors are somewhat acceptable, IMO.

I really do like the proposed approach. That is, to de facto make sure that the last valid TLS configuration is applied. No blips. No hard CVO failures. Although a good point regarding the metrics and alerting. I assume these issues should be transient, but who certainly knows, and this is an important security aspect.

If I understand correctly, the CVO’s goal would be to ensure that it leaves the existing TLS configuration when it cannot correctly assess the desired TLS configuration. Alas, keep the existing configuration on errors.

As to the proposed mechanism, I believe such usage of the annotation is to some degree outside the existing processing pipeline, and it also has some downsides. For example, a ConfigMap may contain critical data that needs to be applied to the cluster. However, I am pondering about the option and its similar alternatives.


I am trying to implement the thing as well, but it might take me a day. I suppose one of the big upsides of the current CVO applying pipeline is its simplicity, which is handy for critical mechanisms. Thus, the introduction of mechanisms able to error out on a number of things needs to be crafted cautiously, IMO.

Copy link
Contributor

@DavidHurta DavidHurta Mar 16, 2026

Choose a reason for hiding this comment

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

I am working on and testing some potential changes for this PR in #1350. However, the CI is busted for me to verify the logs (looks unrelated, but requires further investigation).

I am leaning towards the error out on any issue approach. I'll recheck the logs today, investigate a few things, and write up reasons for my changes and the approach. In case you immediately see an apparent issue with the overall approach of rather erroring out in the modifyConfigmap function, please feel free to leave a comment throughout the day for me to address. Otherwise, I'll get back to you during the day.

The approach should be one of the most straightforward, simplest, ensuring only a valid applied TLS configuration, no blips, and a clear signal to the cluster administrator in case something goes wrong approaches.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thank you for sharing the code improvements. When it comes to the error handling the only difference is a replacement of a warning with an error when apiserver.ObserveTLSSecurityProfile returns errors. Is that correct?

Copy link
Contributor

@DavidHurta DavidHurta Mar 19, 2026

Choose a reason for hiding this comment

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

I believe so, yes. And a bunch of smaller changes around other areas. I have quickly left a few comments on the PR, highlighting them. Although I have glanced at metrics from the CI job of the PR, and I'll need to crosscheck it with more CI jobs.

Copy link
Contributor

@DavidHurta DavidHurta Mar 19, 2026

Choose a reason for hiding this comment

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

We could lock in the expected behavior of apiserver.ObserveTLSSecurityProfile with extensive testing for our use-case. This should leave only transient errors possible, which are, to some degree, acceptable. For example, there is a visible cluster_operator_payload_errors metric that can be observed for general payload issues. I don't see alerting, but I think any prolonged issues should be propagated to the cluster administrator somewhere. Need to check.

The error out approach requires additional investigation to verify its correctness,but it looks promising, IMO.

// 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.

Copy link
Contributor

@DavidHurta DavidHurta Mar 19, 2026

Choose a reason for hiding this comment

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

Yes, the field has now landed in the openshift/api repository.

I do not know how or whether the observeTLSConfiguration will reflect the new tlsAdherence field. With the current PR, we are able to provide the information about the central TLS cluster profile, not how strict the adherence is supposed to be. Potentially, we could not inject the cluster TLS profile in case the adherence is not TLSAdherencePolicyStrictAllComponents, and leave any defaults set in the manifest file. May depend on the consumers, or consumers may fetch the API to check themselves, or observeTLSConfiguration may get additional logic. There are a bunch of options. If there is an approach we can adopt that you are working on in Hypershift, that's also a valid approach.

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 +242 to +250
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