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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pkg/agenticrun/bindata/assets/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
1 change: 1 addition & 0 deletions pkg/agenticrun/bindata/assets/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
78 changes: 76 additions & 2 deletions pkg/agenticrun/consoleplugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package agenticrun

import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"reflect"
Expand All @@ -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
}
Comment thread
jrangelramos marked this conversation as resolved.
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",
Expand All @@ -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{}
Expand Down Expand Up @@ -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
Expand Down
163 changes: 163 additions & 0 deletions pkg/agenticrun/consoleplugin_test.go
Original file line number Diff line number Diff line change
@@ -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)")
}
})
}
}
41 changes: 27 additions & 14 deletions pkg/agenticrun/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -102,6 +105,7 @@ func NewController(
dynamicClient: dynamicClient,
cvGetterFunc: cvGetterFunc,
getCurrentVersionFunc: getCurrentVersionFunc,
apiServerLister: apiServerLister,
config: DefaultConfig(),
}
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
tlsProfile = configv1.TLSProfiles[configv1.TLSProfileIntermediateType]
} else {
tlsProfile = resolveTLSProfileSpec(apiServer.Spec.TLSSecurityProfile)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return applyConsolePluginManifests(ctx, c.client, c.consolePluginImage, tlsProfile)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func (c *Controller) Sync(ctx context.Context, key string) error {
Expand All @@ -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
}
}
}

Expand Down
Loading