diff --git a/pkg/agenticrun/bindata/assets/configmap.yaml b/pkg/agenticrun/bindata/assets/configmap.yaml index f54f33bce0..c708522664 100644 --- a/pkg/agenticrun/bindata/assets/configmap.yaml +++ b/pkg/agenticrun/bindata/assets/configmap.yaml @@ -19,6 +19,10 @@ data: listen [::]:9001 ssl; ssl_certificate /var/cert/tls.crt; ssl_certificate_key /var/cert/tls.key; + ssl_protocols ${SSL_PROTOCOLS}; + ssl_ciphers ${SSL_CIPHERS}; + ssl_prefer_server_ciphers on; + server_tokens off; root /usr/share/nginx/html; } } diff --git a/pkg/agenticrun/bindata/assets/deployment.yaml b/pkg/agenticrun/bindata/assets/deployment.yaml index dcbc46ae90..042948e537 100644 --- a/pkg/agenticrun/bindata/assets/deployment.yaml +++ b/pkg/agenticrun/bindata/assets/deployment.yaml @@ -19,6 +19,7 @@ spec: annotations: target.workload.openshift.io/management: '{"effect": "PreferredDuringScheduling"}' openshift.io/required-scc: restricted-v3 + openshift.io/config-hash: "${CONFIG_HASH}" labels: app: cluster-update-console-plugin spec: diff --git a/pkg/agenticrun/consoleplugin.go b/pkg/agenticrun/consoleplugin.go index 40d8ebe7ba..806f240c35 100644 --- a/pkg/agenticrun/consoleplugin.go +++ b/pkg/agenticrun/consoleplugin.go @@ -2,6 +2,7 @@ package agenticrun import ( "context" + "crypto/sha256" "encoding/json" "fmt" "reflect" @@ -16,12 +17,69 @@ import ( "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/klog/v2" + configv1 "github.com/openshift/api/config/v1" operatorv1 "github.com/openshift/api/operator/v1" "github.com/openshift/cluster-version-operator/pkg/agenticrun/bindata" i "github.com/openshift/cluster-version-operator/pkg/internal" ) +var tlsVersionToNginxProtocols = map[configv1.TLSProtocolVersion]string{ + configv1.VersionTLS10: "TLSv1 TLSv1.1 TLSv1.2 TLSv1.3", + configv1.VersionTLS11: "TLSv1.1 TLSv1.2 TLSv1.3", + configv1.VersionTLS12: "TLSv1.2 TLSv1.3", + configv1.VersionTLS13: "TLSv1.3", +} + +func resolveTLSProfileSpec(tlsSecurityProfile *configv1.TLSSecurityProfile) *configv1.TLSProfileSpec { + if tlsSecurityProfile == nil { + return configv1.TLSProfiles[configv1.TLSProfileIntermediateType] + } + if tlsSecurityProfile.Type == configv1.TLSProfileCustomType && tlsSecurityProfile.Custom != nil { + return &tlsSecurityProfile.Custom.TLSProfileSpec + } + if spec, ok := configv1.TLSProfiles[tlsSecurityProfile.Type]; ok { + return spec + } + klog.Warningf("Unknown TLS security profile type %q, falling back to Intermediate", tlsSecurityProfile.Type) + return configv1.TLSProfiles[configv1.TLSProfileIntermediateType] +} + +func nginxTLSDirectives(profile *configv1.TLSProfileSpec) (sslProtocols, sslCiphers string) { + sslProtocols, ok := tlsVersionToNginxProtocols[profile.MinTLSVersion] + if !ok { + klog.Warningf("No nginx protocol mapping for MinTLSVersion %q, falling back to TLS 1.2", profile.MinTLSVersion) + sslProtocols = tlsVersionToNginxProtocols[configv1.VersionTLS12] + } + + // TLS 1.3 ciphers (TLS_*) are not configurable via nginx ssl_ciphers — + // they are always enabled when TLS 1.3 is negotiated. + var ciphers []string + skippedTLS13 := false + for _, c := range profile.Ciphers { + if strings.HasPrefix(c, "TLS_") { + skippedTLS13 = true + } else { + ciphers = append(ciphers, c) + } + } + if skippedTLS13 { + klog.Warningf("Skipping TLS 1.3 ciphers from ssl_ciphers directive — nginx enables them automatically when TLS 1.3 is negotiated") + } + + // Modern profile has only TLS 1.3 ciphers, which all get filtered above. + // Nginx calls SSL_CTX_set_cipher_list (ngx_event_openssl.c ngx_ssl_ciphers) + // at startup regardless of protocol version; OpenSSL rejects an empty string + // (ssl_lib.c ssl_create_cipher_list). Use a single placeholder cipher — it is + // never negotiated when only TLS 1.3 is active. + if len(ciphers) == 0 { + ciphers = []string{"ECDHE-ECDSA-AES128-GCM-SHA256"} + } + + sslCiphers = strings.Join(ciphers, ":") + return sslProtocols, sslCiphers +} + var consolePluginAssets = []string{ "assets/namespace.yaml", "assets/serviceaccount.yaml", @@ -33,12 +91,25 @@ var consolePluginAssets = []string{ "assets/consoleplugin.yaml", } -func applyConsolePluginManifests(ctx context.Context, client ctrlruntimeclient.Client, image string) error { +func applyConsolePluginManifests(ctx context.Context, client ctrlruntimeclient.Client, image string, tlsProfile *configv1.TLSProfileSpec) error { + sslProtocols, sslCiphers := nginxTLSDirectives(tlsProfile) + + configMapRaw := bindata.MustAsset("assets/configmap.yaml") + rendered := strings.ReplaceAll(string(configMapRaw), "${SSL_PROTOCOLS}", sslProtocols) + rendered = strings.ReplaceAll(rendered, "${SSL_CIPHERS}", sslCiphers) + configHash := fmt.Sprintf("%x", sha256.Sum256([]byte(rendered))) + for _, asset := range consolePluginAssets { - raw := bindata.MustAsset(asset) + var raw []byte + if asset == "assets/configmap.yaml" { + raw = []byte(rendered) + } else { + raw = bindata.MustAsset(asset) + } if asset == "assets/deployment.yaml" { raw = []byte(strings.ReplaceAll(string(raw), "${IMAGE}", image)) + raw = []byte(strings.ReplaceAll(string(raw), "${CONFIG_HASH}", configHash)) } obj := &unstructured.Unstructured{} @@ -68,6 +139,9 @@ func applyConsolePluginManifests(ctx context.Context, client ctrlruntimeclient.C if err := client.Update(ctx, obj); err != nil { return fmt.Errorf("updating %s %s: %w", obj.GetKind(), obj.GetName(), err) } + if asset == "assets/configmap.yaml" { + klog.Infof("Console plugin ConfigMap updated. Deployment rollout will follow") + } klog.V(i.Normal).Infof("Updated console plugin %s %s", obj.GetKind(), obj.GetName()) } return nil diff --git a/pkg/agenticrun/consoleplugin_test.go b/pkg/agenticrun/consoleplugin_test.go new file mode 100644 index 0000000000..eaaf76624f --- /dev/null +++ b/pkg/agenticrun/consoleplugin_test.go @@ -0,0 +1,163 @@ +package agenticrun + +import ( + "strings" + "testing" + + configv1 "github.com/openshift/api/config/v1" +) + +func TestResolveTLSProfileSpec(t *testing.T) { + tests := []struct { + name string + profile *configv1.TLSSecurityProfile + wantMinVersion configv1.TLSProtocolVersion + }{ + { + name: "nil defaults to Intermediate", + profile: nil, + wantMinVersion: configv1.VersionTLS12, + }, + { + name: "Intermediate", + profile: &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileIntermediateType, + Intermediate: &configv1.IntermediateTLSProfile{}, + }, + wantMinVersion: configv1.VersionTLS12, + }, + { + name: "Modern", + profile: &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileModernType, + Modern: &configv1.ModernTLSProfile{}, + }, + wantMinVersion: configv1.VersionTLS13, + }, + { + name: "Old", + profile: &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileOldType, + Old: &configv1.OldTLSProfile{}, + }, + wantMinVersion: configv1.VersionTLS10, + }, + { + name: "Custom", + profile: &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileCustomType, + Custom: &configv1.CustomTLSProfile{ + TLSProfileSpec: configv1.TLSProfileSpec{ + Ciphers: []string{"ECDHE-RSA-AES128-GCM-SHA256"}, + MinTLSVersion: configv1.VersionTLS13, + }, + }, + }, + wantMinVersion: configv1.VersionTLS13, + }, + { + name: "Custom with nil Custom field falls back to Intermediate", + profile: &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileCustomType, + Custom: nil, + }, + wantMinVersion: configv1.VersionTLS12, + }, + { + name: "unknown type falls back to Intermediate", + profile: &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileType("FutureTLSType"), + }, + wantMinVersion: configv1.VersionTLS12, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spec := resolveTLSProfileSpec(tt.profile) + if spec == nil { + t.Fatal("got nil spec") + } + if spec.MinTLSVersion != tt.wantMinVersion { + t.Errorf("MinTLSVersion = %q, want %q", spec.MinTLSVersion, tt.wantMinVersion) + } + }) + } +} + +func TestNginxTLSDirectives(t *testing.T) { + tests := []struct { + name string + profile *configv1.TLSProfileSpec + wantProtocols string + wantCiphers string + noCipherContains string // substring that must NOT appear in ciphers + }{ + { + name: "Intermediate profile", + profile: configv1.TLSProfiles[configv1.TLSProfileIntermediateType], + wantProtocols: "TLSv1.2 TLSv1.3", + }, + { + name: "Modern profile uses TLS 1.3 only with placeholder cipher", + profile: configv1.TLSProfiles[configv1.TLSProfileModernType], + wantProtocols: "TLSv1.3", + wantCiphers: "ECDHE-ECDSA-AES128-GCM-SHA256", + noCipherContains: "TLS_", + }, + { + name: "Old profile", + profile: configv1.TLSProfiles[configv1.TLSProfileOldType], + wantProtocols: "TLSv1 TLSv1.1 TLSv1.2 TLSv1.3", + }, + { + name: "unknown MinTLSVersion falls back to TLS 1.2 protocols", + profile: &configv1.TLSProfileSpec{ + MinTLSVersion: configv1.TLSProtocolVersion("VersionTLS99"), + Ciphers: []string{"ECDHE-RSA-AES128-GCM-SHA256"}, + }, + wantProtocols: "TLSv1.2 TLSv1.3", + wantCiphers: "ECDHE-RSA-AES128-GCM-SHA256", + }, + { + name: "TLS 1.3 ciphers are filtered out", + profile: &configv1.TLSProfileSpec{ + MinTLSVersion: configv1.VersionTLS12, + Ciphers: []string{ + "TLS_AES_128_GCM_SHA256", + "ECDHE-RSA-AES128-GCM-SHA256", + "TLS_CHACHA20_POLY1305_SHA256", + "ECDHE-RSA-AES256-GCM-SHA384", + }, + }, + wantProtocols: "TLSv1.2 TLSv1.3", + wantCiphers: "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384", + noCipherContains: "TLS_", + }, + { + name: "all TLS 1.3 ciphers get placeholder", + profile: &configv1.TLSProfileSpec{ + MinTLSVersion: configv1.VersionTLS13, + Ciphers: []string{"TLS_AES_128_GCM_SHA256", "TLS_AES_256_GCM_SHA384"}, + }, + wantProtocols: "TLSv1.3", + wantCiphers: "ECDHE-ECDSA-AES128-GCM-SHA256", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + protocols, ciphers := nginxTLSDirectives(tt.profile) + if protocols != tt.wantProtocols { + t.Errorf("protocols = %q, want %q", protocols, tt.wantProtocols) + } + if tt.wantCiphers != "" && ciphers != tt.wantCiphers { + t.Errorf("ciphers = %q, want %q", ciphers, tt.wantCiphers) + } + if tt.noCipherContains != "" && strings.Contains(ciphers, tt.noCipherContains) { + t.Errorf("ciphers %q should not contain %q", ciphers, tt.noCipherContains) + } + if ciphers == "" { + t.Error("ciphers must never be empty (nginx rejects empty ssl_ciphers)") + } + }) + } +} diff --git a/pkg/agenticrun/controller.go b/pkg/agenticrun/controller.go index 6c293491f1..201dce6fd7 100644 --- a/pkg/agenticrun/controller.go +++ b/pkg/agenticrun/controller.go @@ -23,6 +23,7 @@ import ( "k8s.io/klog/v2" configv1 "github.com/openshift/api/config/v1" + configlistersv1 "github.com/openshift/client-go/config/listers/config/v1" agenticrunv1alpha1 "github.com/openshift/lightspeed-agentic-operator/api/v1alpha1" i "github.com/openshift/cluster-version-operator/pkg/internal" @@ -61,9 +62,10 @@ type Controller struct { dynamicClient dynamic.Interface cvGetterFunc cvGetterFunc getCurrentVersionFunc getCurrentVersionFunc + apiServerLister configlistersv1.APIServerLister config Config consolePluginImage string - consolePluginEnsured bool + consolePluginEnabled bool crdAvailableCache bool crdLastChecked time.Time hypershift bool @@ -91,6 +93,7 @@ func NewController( dynamicClient dynamic.Interface, cvGetterFunc cvGetterFunc, getCurrentVersionFunc getCurrentVersionFunc, + apiServerLister configlistersv1.APIServerLister, ) *Controller { return &Controller{ queueKey: fmt.Sprintf("ClusterVersionOperator/%s", controllerName), @@ -102,6 +105,7 @@ func NewController( dynamicClient: dynamicClient, cvGetterFunc: cvGetterFunc, getCurrentVersionFunc: getCurrentVersionFunc, + apiServerLister: apiServerLister, config: DefaultConfig(), } } @@ -145,10 +149,7 @@ func (c *Controller) SetConsoleCapabilityFunc(f func() bool) { } func (c *Controller) SetConsolePluginImage(image string) { - if c.consolePluginImage != image { - c.consolePluginImage = image - c.consolePluginEnsured = false - } + c.consolePluginImage = image } func (c *Controller) SetSkillsImage(image string) { @@ -186,7 +187,17 @@ func (c *Controller) ensureConsolePlugin(ctx context.Context) error { if c.consolePluginImage == "" { return fmt.Errorf("console plugin image not set") } - return applyConsolePluginManifests(ctx, c.client, c.consolePluginImage) + + var tlsProfile *configv1.TLSProfileSpec + apiServer, err := c.apiServerLister.Get("cluster") + if err != nil { + klog.Warningf("Could not read APIServer config, using Intermediate TLS defaults: %v", err) + tlsProfile = configv1.TLSProfiles[configv1.TLSProfileIntermediateType] + } else { + tlsProfile = resolveTLSProfileSpec(apiServer.Spec.TLSSecurityProfile) + } + + return applyConsolePluginManifests(ctx, c.client, c.consolePluginImage, tlsProfile) } func (c *Controller) Sync(ctx context.Context, key string) error { @@ -202,19 +213,21 @@ func (c *Controller) Sync(ctx context.Context, key string) error { } else if err := cleanupConsolePluginManifests(ctx, c.client); err != nil { klog.V(i.Normal).Infof("Failed to clean up console plugin: %v", err) } - c.consolePluginEnsured = false + c.consolePluginEnabled = false return nil } - if c.shouldDeployConsolePlugin() && !c.consolePluginEnsured { + if c.shouldDeployConsolePlugin() { if err := c.ensureConsolePlugin(ctx); err != nil { klog.V(i.Normal).Infof("Failed to ensure console plugin: %v", err) - } else if err := waitForPluginReady(ctx, c.client); err != nil { - klog.V(i.Normal).Infof("Console plugin not ready yet, deferring enable: %v", err) - } else if err := enableConsolePlugin(ctx, c.client); err != nil { - klog.V(i.Normal).Infof("Failed to enable console plugin: %v", err) - } else { - c.consolePluginEnsured = true + } else if !c.consolePluginEnabled { + if err := waitForPluginReady(ctx, c.client); err != nil { + klog.V(i.Normal).Infof("Console plugin not ready yet, deferring enable: %v", err) + } else if err := enableConsolePlugin(ctx, c.client); err != nil { + klog.V(i.Normal).Infof("Failed to enable console plugin: %v", err) + } else { + c.consolePluginEnabled = true + } } } diff --git a/pkg/agenticrun/controller_test.go b/pkg/agenticrun/controller_test.go index f6f6588664..10709becda 100644 --- a/pkg/agenticrun/controller_test.go +++ b/pkg/agenticrun/controller_test.go @@ -128,7 +128,7 @@ Update path: Recommended t.Run(tt.name, func(t *testing.T) { c := NewController(tt.updatesGetterFunc, tt.client, nil, tt.cvGetterFunc, func() string { return "4.22.1" - }) + }, nil) c.config.SkillsImage = "registry.example.com/agentic-skills:latest" c.crdAvailableCache = true c.crdLastChecked = time.Now() diff --git a/pkg/cvo/availableupdates_test.go b/pkg/cvo/availableupdates_test.go index 1f20de720f..6be360af02 100644 --- a/pkg/cvo/availableupdates_test.go +++ b/pkg/cvo/availableupdates_test.go @@ -215,6 +215,7 @@ func newOperator(url string, cluster release, promqlMock clusterconditions.Condi func() string { return operator.release.Version }, + nil, ) return availableUpdates, operator } @@ -1252,7 +1253,7 @@ func TestOperator_syncAvailableUpdates_noticeResolvedAlertsQuickly(t *testing.T) t.Fatalf("accept risk feature is not enabled") } optr.enabledCVOFeatureGates = cvgGates - optr.agenticRunController = agenticrun.NewController(nil, nil, nil, nil, nil) + optr.agenticRunController = agenticrun.NewController(nil, nil, nil, nil, nil, nil) err := optr.syncAvailableUpdates(context.Background(), &configv1.ClusterVersion{ Spec: configv1.ClusterVersionSpec{ DesiredUpdate: &configv1.Update{ diff --git a/pkg/cvo/cvo.go b/pkg/cvo/cvo.go index 93842c410b..c0771c1445 100644 --- a/pkg/cvo/cvo.go +++ b/pkg/cvo/cvo.go @@ -254,6 +254,7 @@ func New( cvoGates featuregates.CvoGateChecker, startingEnabledManifestFeatureGates sets.Set[string], rtClient runtimeclient.Client, + apiServerLister configlistersv1.APIServerLister, ) (*Operator, error) { eventBroadcaster := record.NewBroadcaster() eventBroadcaster.StartLogging(klog.Infof) @@ -369,6 +370,7 @@ func New( func() string { return optr.release.Version }, + apiServerLister, ) return optr, nil diff --git a/pkg/cvo/cvo_test.go b/pkg/cvo/cvo_test.go index b2a1b11041..a623fbe368 100644 --- a/pkg/cvo/cvo_test.go +++ b/pkg/cvo/cvo_test.go @@ -2760,7 +2760,7 @@ func TestOperator_availableUpdatesSync(t *testing.T) { return &configv1.ClusterVersion{}, nil }, func() string { return optr.release.Version - }) + }, nil) err := optr.availableUpdatesSync(ctx, optr.queueKey()) if err != nil && tt.wantErr == nil { t.Fatalf("Operator.sync() unexpected error: %v", err) diff --git a/pkg/start/start.go b/pkg/start/start.go index b18a6530cd..fec721a44d 100644 --- a/pkg/start/start.go +++ b/pkg/start/start.go @@ -687,6 +687,7 @@ func (o *Options) NewControllerContext( startingCvoGates, startingEnabledManifestFeatureGates, rtClient, + configInformerFactory.Config().V1().APIServers().Lister(), ) if err != nil { return nil, err