diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..050c80289 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +bundle/** linguist-generated=true diff --git a/bundle.Dockerfile b/bundle.Dockerfile index 924578a9a..a7a6e955f 100644 --- a/bundle.Dockerfile +++ b/bundle.Dockerfile @@ -7,9 +7,9 @@ LABEL operators.operatorframework.io.bundle.metadata.v1=metadata/ LABEL operators.operatorframework.io.bundle.package.v1=file-integrity-operator LABEL operators.operatorframework.io.bundle.channels.v1=alpha LABEL operators.operatorframework.io.bundle.channel.default.v1=alpha -LABEL operators.operatorframework.io.metrics.builder=operator-sdk-v1.42.2 +LABEL operators.operatorframework.io.metrics.builder=operator-sdk-v1.27.0 LABEL operators.operatorframework.io.metrics.mediatype.v1=metrics+v1 -LABEL operators.operatorframework.io.metrics.project_layout=go.kubebuilder.io/v4 +LABEL operators.operatorframework.io.metrics.project_layout=go.kubebuilder.io/v3 # Labels for testing. LABEL operators.operatorframework.io.test.mediatype.v1=scorecard+v1 diff --git a/bundle/manifests/file-integrity-operator.clusterserviceversion.yaml b/bundle/manifests/file-integrity-operator.clusterserviceversion.yaml index 8820192cc..005607321 100644 --- a/bundle/manifests/file-integrity-operator.clusterserviceversion.yaml +++ b/bundle/manifests/file-integrity-operator.clusterserviceversion.yaml @@ -19,15 +19,15 @@ metadata: ] capabilities: Seamless Upgrades categories: Monitoring,Security - createdAt: "2026-05-04T11:14:39Z" + createdAt: "2026-09-15T14:42:22Z" olm.skipRange: '>=1.0.0 <1.4.0' operatorframework.io/cluster-monitoring: "true" operatorframework.io/suggested-namespace: openshift-file-integrity operators.openshift.io/infrastructure-features: '["disconnected", "fips"]' operators.openshift.io/valid-subscription: '["OpenShift Kubernetes Engine", "OpenShift Container Platform", "OpenShift Platform Plus"]' - operators.operatorframework.io/builder: operator-sdk-v1.42.2 - operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 + operators.operatorframework.io/builder: operator-sdk-v1.27.0 + operators.operatorframework.io/project_layout: go.kubebuilder.io/v3 repository: https://github.com/openshift/file-integrity-operator labels: operatorframework.io/arch.amd64: supported @@ -75,6 +75,14 @@ spec: - get - list - watch + - apiGroups: + - config.openshift.io + resources: + - apiservers + verbs: + - get + - list + - watch serviceAccountName: file-integrity-operator deployments: - name: file-integrity-operator @@ -337,4 +345,3 @@ spec: - image: quay.io/file-integrity-operator/file-integrity-operator:latest name: operator version: 1.4.0 - replaces: file-integrity-operator.v1.3.8 diff --git a/bundle/metadata/annotations.yaml b/bundle/metadata/annotations.yaml index 2eccfd980..3d29017e6 100644 --- a/bundle/metadata/annotations.yaml +++ b/bundle/metadata/annotations.yaml @@ -6,9 +6,9 @@ annotations: operators.operatorframework.io.bundle.package.v1: file-integrity-operator operators.operatorframework.io.bundle.channels.v1: alpha operators.operatorframework.io.bundle.channel.default.v1: alpha - operators.operatorframework.io.metrics.builder: operator-sdk-v1.42.2 + operators.operatorframework.io.metrics.builder: operator-sdk-v1.27.0 operators.operatorframework.io.metrics.mediatype.v1: metrics+v1 - operators.operatorframework.io.metrics.project_layout: go.kubebuilder.io/v4 + operators.operatorframework.io.metrics.project_layout: go.kubebuilder.io/v3 # Annotations for testing. operators.operatorframework.io.test.mediatype.v1: scorecard+v1 diff --git a/cmd/manager/manager_suite_test.go b/cmd/manager/manager_suite_test.go index b50ff9105..f158cec46 100644 --- a/cmd/manager/manager_suite_test.go +++ b/cmd/manager/manager_suite_test.go @@ -3,11 +3,14 @@ package manager import ( "testing" + "github.com/go-logr/logr/testr" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + ctrl "sigs.k8s.io/controller-runtime" ) func TestManager(t *testing.T) { + ctrl.SetLogger(testr.New(t)) RegisterFailHandler(Fail) RunSpecs(t, "Manager Suite") } diff --git a/cmd/manager/operator.go b/cmd/manager/operator.go index ecea42679..4781490b7 100644 --- a/cmd/manager/operator.go +++ b/cmd/manager/operator.go @@ -17,7 +17,6 @@ package manager import ( "context" - "crypto/tls" "errors" "flag" "fmt" @@ -28,6 +27,7 @@ import ( "github.com/cenkalti/backoff/v4" "github.com/spf13/cobra" + configv1 "github.com/openshift/api/config/v1" v1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" kerr "k8s.io/apimachinery/pkg/api/errors" @@ -47,7 +47,6 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - "sigs.k8s.io/controller-runtime/pkg/webhook" monitoring "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" monclientv1 "github.com/prometheus-operator/prometheus-operator/pkg/client/versioned/typed/monitoring/v1" @@ -83,6 +82,7 @@ func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(v1alpha1.AddToScheme(scheme)) + utilruntime.Must(configv1.Install(scheme)) //+kubebuilder:scaffold:scheme } @@ -93,7 +93,6 @@ var ( defaultPrometheusAlertName = "file-integrity" metricsServiceName = "metrics" leaderElectionID = "962a0cf2.openshift.io" - enableHTTP2 = false ) func printVersion() { @@ -127,16 +126,12 @@ func RunOperator(cmd *cobra.Command, args []string) { kubeClient := kubernetes.NewForConfigOrDie(cfg) monitoringClient := monclientv1.NewForConfigOrDie(cfg) - ctx := context.TODO() + ctx, cancel := context.WithCancel(context.TODO()) + defer cancel() log.Info("Registering Components.") - disableHTTP2 := func(c *tls.Config) { - if enableHTTP2 { - return - } - c.NextProtos = []string{"http/1.1"} - } + tlsSettings := makeClusterTLSSettings(ctx, cfg) c := cache.Options{DefaultNamespaces: map[string]cache.Config{namespace: {}}} mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Cache: c, @@ -152,7 +147,7 @@ func RunOperator(cmd *cobra.Command, args []string) { Scheme: scheme, Metrics: metricsserver.Options{BindAddress: fmt.Sprintf("%s:%d", metricsHost, metricsPort)}, HealthProbeBindAddress: ":8081", - WebhookServer: webhook.NewServer(webhook.Options{Port: 9443, TLSOpts: []func(config *tls.Config){disableHTTP2}}), + WebhookServer: makeWebhookServer(tlsSettings), LeaderElection: true, LeaderElectionID: leaderElectionID, }) @@ -166,6 +161,13 @@ func RunOperator(cmd *cobra.Command, args []string) { log.Error(err, "Error registering metrics") os.Exit(1) } + configureMetricsTLSProfile(met, tlsSettings) + + watcher := makeSecurityProfileWatcher(mgr.GetClient(), tlsSettings, cancel) + if err := watcher.SetupWithManager(mgr); err != nil { + log.Error(err, "Unable to set up TLS security profile watcher") + os.Exit(1) + } //+kubebuilder:scaffold:builder @@ -223,7 +225,16 @@ func RunOperator(cmd *cobra.Command, args []string) { } log.Info("Starting manager") - if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + + // Start the Cmd. The context is cancelled either by OS signals or by + // the SecurityProfileWatcher when TLS configuration changes, causing + // a graceful shutdown so the pod restarts with updated TLS settings. + sigCtx := ctrl.SetupSignalHandler() + go func() { + <-sigCtx.Done() + cancel() + }() + if err := mgr.Start(ctx); err != nil { log.Error(err, "Manager exited non-zero") os.Exit(1) } diff --git a/cmd/manager/tls.go b/cmd/manager/tls.go new file mode 100644 index 000000000..730952615 --- /dev/null +++ b/cmd/manager/tls.go @@ -0,0 +1,142 @@ +package manager + +import ( + "context" + "crypto/tls" + "os" + "time" + + configv1 "github.com/openshift/api/config/v1" + tlspkg "github.com/openshift/controller-runtime-common/pkg/tls" + "github.com/openshift/file-integrity-operator/pkg/controller/metrics" + libgocrypto "github.com/openshift/library-go/pkg/crypto" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook" +) + +// tlsLookupTimeout bounds the cluster TLS profile/adherence lookups so an +// unresponsive API server cannot block operator startup or the result server +// indefinitely; on timeout the caller falls back to secure defaults. +const tlsLookupTimeout = 30 * time.Second + +// clusterTLSSettings captures the cluster TLS profile and adherence policy +// used to configure the manager's TLS endpoints and watch for future changes. +type clusterTLSSettings struct { + profile configv1.TLSProfileSpec + adherence configv1.TLSAdherencePolicy +} + +// fetchClusterTLSSettings fetches the cluster's TLS security profile and adherence +// policy from the APIServer resource using c, bounding both lookups with +// tlsLookupTimeout (a child of ctx). On any lookup error it falls back to +// secure defaults (default ciphers/min version and a "no opinion" adherence +// policy) so callers always receive a usable pair. +func fetchClusterTLSSettings(ctx context.Context, c client.Client) (configv1.TLSProfileSpec, configv1.TLSAdherencePolicy) { + lookupCtx, cancel := context.WithTimeout(ctx, tlsLookupTimeout) + defer cancel() + + profile, err := tlspkg.FetchAPIServerTLSProfile(lookupCtx, c) + if err != nil { + log.Info("Could not fetch APIServer TLS profile, using defaults", "error", err) + profile = configv1.TLSProfileSpec{ + Ciphers: tlspkg.DefaultTLSCiphers, + MinTLSVersion: tlspkg.DefaultMinTLSVersion, + } + } + + adherence, err := tlspkg.FetchAPIServerTLSAdherencePolicy(lookupCtx, c) + if err != nil { + log.Info("Could not fetch APIServer TLS adherence policy, using defaults", "error", err) + adherence = configv1.TLSAdherencePolicyNoOpinion + } + + return profile, adherence +} + +// applyClusterTLSSettings conditionally applies the cluster TLS security +// settings to c. It is a no-op when the adherence policy does not require +// strict adherence. Returns any cipher suites unsupported by Go. +func applyClusterTLSSettings(c *tls.Config, s *clusterTLSSettings) []string { + if !libgocrypto.ShouldHonorClusterTLSProfile(s.adherence) { + return nil + } + fn, unsupported := tlspkg.NewTLSConfigFromProfile(s.profile) + fn(c) + return unsupported +} + +// makeClusterTLSSettings returns the cluster TLS security profile and adherence +// policy. These are used to configure all TLS endpoints at startup and to +// detect changes later via the SecurityProfileWatcher. The lookups are bounded +// by a timeout and fall back to secure defaults on error. +func makeClusterTLSSettings(ctx context.Context, cfg *rest.Config) *clusterTLSSettings { + // Build a client for fetching the cluster TLS profile and adherence + // policy before the manager is started. + preStartClient, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + log.Error(err, "Failed to create pre-start client for TLS profile lookup") + os.Exit(1) + } + + profile, adherence := fetchClusterTLSSettings(ctx, preStartClient) + return &clusterTLSSettings{profile, adherence} +} + +// makeWebhookServer returns a webhook.Server configured with the cluster TLS +// security profile and adherence policy. It logs any cipher suites unsupported +// by Go. +func makeWebhookServer(state *clusterTLSSettings) webhook.Server { + opts := []func(config *tls.Config){ + func(c *tls.Config) { + c.NextProtos = []string{"http/1.1"} + }, + func(c *tls.Config) { + if unsupported := applyClusterTLSSettings(c, state); len(unsupported) > 0 { + log.Info("TLS profile contains ciphers unsupported by Go", "unsupported", unsupported) + } + }, + } + return webhook.NewServer(webhook.Options{Port: 9443, TLSOpts: opts}) +} + +// configureMetricsTLSProfile configures the metrics server with the cluster TLS +// security profile and adherence policy. It is a no-op when the adherence policy +// does not require strict adherence. +func configureMetricsTLSProfile(met *metrics.Metrics, s *clusterTLSSettings) { + // If the tlsAdherence policy requires strict adherence, configure the + // metrics server to use the cluster's TLS security profile. + if libgocrypto.ShouldHonorClusterTLSProfile(s.adherence) { + met.SetTLSProfileSpec(s.profile) + } +} + +// makeSecurityProfileWatcher returns a SecurityProfileWatcher configured with +// the cluster TLS security profile and adherence policy. It sets up callbacks +// to cancel the manager context on any changes, so the pod restarts with the +// new TLS configuration applied. +func makeSecurityProfileWatcher(client client.Client, s *clusterTLSSettings, cancel func()) *tlspkg.SecurityProfileWatcher { + // Set up the SecurityProfileWatcher to detect APIServer TLS profile + // and adherence policy changes. On change, cancel the context so the + // manager shuts down gracefully and the pod restarts with the new + // TLS configuration applied. + return &tlspkg.SecurityProfileWatcher{ + Client: client, + InitialTLSProfileSpec: s.profile, + InitialTLSAdherencePolicy: s.adherence, + OnProfileChange: func(_ context.Context, oldProfile, newProfile configv1.TLSProfileSpec) { + log.Info("Cluster TLS profile changed, initiating graceful shutdown to reload", + "oldMinTLSVersion", oldProfile.MinTLSVersion, + "newMinTLSVersion", newProfile.MinTLSVersion, + ) + cancel() + }, + OnAdherencePolicyChange: func(_ context.Context, oldPolicy, newPolicy configv1.TLSAdherencePolicy) { + log.Info("Cluster TLS adherence policy changed, initiating graceful shutdown to reload", + "oldPolicy", oldPolicy, + "newPolicy", newPolicy, + ) + cancel() + }, + } +} diff --git a/cmd/manager/tls_test.go b/cmd/manager/tls_test.go new file mode 100644 index 000000000..72be8a4f9 --- /dev/null +++ b/cmd/manager/tls_test.go @@ -0,0 +1,94 @@ +package manager + +import ( + "context" + "crypto/tls" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + configv1 "github.com/openshift/api/config/v1" + tlspkg "github.com/openshift/controller-runtime-common/pkg/tls" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +var _ = Describe("fetchClusterTLSSettings", func() { + Context("when the APIServer resource specifies a profile and adherence policy", func() { + It("returns the configured profile and adherence policy", func() { + apiServer := &configv1.APIServer{ + ObjectMeta: metav1.ObjectMeta{Name: tlspkg.APIServerName}, + Spec: configv1.APIServerSpec{ + TLSSecurityProfile: &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileModernType, + }, + TLSAdherence: configv1.TLSAdherencePolicyStrictAllComponents, + }, + } + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(apiServer).Build() + + profile, adherence := fetchClusterTLSSettings(context.Background(), cl) + Expect(adherence).To(Equal(configv1.TLSAdherencePolicyStrictAllComponents)) + Expect(profile.MinTLSVersion).To(Equal(configv1.TLSProfiles[configv1.TLSProfileModernType].MinTLSVersion)) + }) + }) + + Context("when the APIServer resource is missing", func() { + It("falls back to secure defaults and a no-opinion adherence policy", func() { + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + + profile, adherence := fetchClusterTLSSettings(context.Background(), cl) + Expect(adherence).To(Equal(configv1.TLSAdherencePolicyNoOpinion)) + Expect(profile.MinTLSVersion).To(Equal(tlspkg.DefaultMinTLSVersion)) + Expect(profile.Ciphers).To(Equal(tlspkg.DefaultTLSCiphers)) + }) + }) +}) + +var _ = Describe("applyClusterTLSProfile", func() { + var baseConfig *tls.Config + + BeforeEach(func() { + baseConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + } + }) + + Context("when adherence is NoOpinion", func() { + It("does not modify the config", func() { + profile := *configv1.TLSProfiles[configv1.TLSProfileModernType] + unsupported := applyClusterTLSSettings(baseConfig, &clusterTLSSettings{profile, configv1.TLSAdherencePolicyNoOpinion}) + + Expect(unsupported).To(BeNil()) + Expect(baseConfig.MinVersion).To(Equal(uint16(tls.VersionTLS12))) + }) + }) + + Context("when adherence is LegacyAdheringComponentsOnly", func() { + It("does not modify the config", func() { + profile := *configv1.TLSProfiles[configv1.TLSProfileModernType] + unsupported := applyClusterTLSSettings(baseConfig, &clusterTLSSettings{profile, configv1.TLSAdherencePolicyLegacyAdheringComponentsOnly}) + + Expect(unsupported).To(BeNil()) + Expect(baseConfig.MinVersion).To(Equal(uint16(tls.VersionTLS12))) + }) + }) + + Context("when adherence is StrictAllComponents", func() { + It("applies the Modern profile (TLS 1.3)", func() { + profile := *configv1.TLSProfiles[configv1.TLSProfileModernType] + unsupported := applyClusterTLSSettings(baseConfig, &clusterTLSSettings{profile, configv1.TLSAdherencePolicyStrictAllComponents}) + + Expect(unsupported).To(BeEmpty()) + Expect(baseConfig.MinVersion).To(Equal(uint16(tls.VersionTLS13))) + }) + + It("applies the Intermediate profile (TLS 1.2)", func() { + profile := *configv1.TLSProfiles[configv1.TLSProfileIntermediateType] + unsupported := applyClusterTLSSettings(baseConfig, &clusterTLSSettings{profile, configv1.TLSAdherencePolicyStrictAllComponents}) + + Expect(baseConfig.MinVersion).To(Equal(uint16(tls.VersionTLS12))) + Expect(baseConfig.CipherSuites).NotTo(BeEmpty()) + _ = unsupported + }) + }) +}) diff --git a/config/rbac/operator_clusterrole.yaml b/config/rbac/operator_clusterrole.yaml index 55363f6f2..051b02690 100644 --- a/config/rbac/operator_clusterrole.yaml +++ b/config/rbac/operator_clusterrole.yaml @@ -19,3 +19,11 @@ rules: - get - list - watch + - apiGroups: + - config.openshift.io + resources: + - apiservers + verbs: + - get + - list + - watch diff --git a/go.mod b/go.mod index d5bc7c5a1..106731ac9 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/onsi/ginkgo v1.16.5 github.com/onsi/gomega v1.39.1 github.com/openshift/api v0.0.0-20260901194050-81278704edb0 + github.com/openshift/controller-runtime-common v0.0.0-20260813135806-e1187ec555fc github.com/openshift/library-go v0.0.0-20260807194649-ee0a87843dda github.com/openshift/machine-config-operator v0.0.1-0.20260410020757-449e78f7ec94 github.com/pborman/uuid v1.2.1 diff --git a/go.sum b/go.sum index f0767f94e..67d38758c 100644 --- a/go.sum +++ b/go.sum @@ -399,6 +399,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/openshift/api v0.0.0-20260901194050-81278704edb0 h1:9PTDE/0weDetokFkqYdHtZQDcULZhJSm5gNF3GceH4A= github.com/openshift/api v0.0.0-20260901194050-81278704edb0/go.mod h1:k6qH5QOVa5GDln2VVm8Jz4NV3Z7R2SATHFLwGS6Wh3M= +github.com/openshift/controller-runtime-common v0.0.0-20260813135806-e1187ec555fc h1:7v9PXV3WR5h8h4x44yfzFKwF0KrdiG6YTKV+26YIcfo= +github.com/openshift/controller-runtime-common v0.0.0-20260813135806-e1187ec555fc/go.mod h1:zyxfp5JNVAFiTBwG0j+RxKPeLUVIEuzkeGMvTeYMaoA= github.com/openshift/library-go v0.0.0-20260807194649-ee0a87843dda h1:yVQlJiQZGCi3ydS74ysAkLbP+bVbvAQaAuCDZi6UgDY= github.com/openshift/library-go v0.0.0-20260807194649-ee0a87843dda/go.mod h1:IrZbEK+wVUMEd+aXzYR2DCCh0p5IaQ7DvycYGP7qIYM= github.com/openshift/machine-config-operator v0.0.1-0.20260410020757-449e78f7ec94 h1:yOOrCtGX3QZ8EOjhwjFrR7fOKoFO9CQhT+iOEvzrbrA= diff --git a/pkg/controller/metrics/metrics.go b/pkg/controller/metrics/metrics.go index 8b1e8956b..18850111c 100644 --- a/pkg/controller/metrics/metrics.go +++ b/pkg/controller/metrics/metrics.go @@ -17,6 +17,8 @@ import ( ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + configv1 "github.com/openshift/api/config/v1" + tlspkg "github.com/openshift/controller-runtime-common/pkg/tls" libgocrypto "github.com/openshift/library-go/pkg/crypto" ) @@ -77,6 +79,7 @@ var ( type Metrics struct { impl impl log logr.Logger + tlsProfileSpec *configv1.TLSProfileSpec metricFileIntegrityPhase *prometheus.CounterVec metricFileIntegrityError *prometheus.CounterVec metricFileIntegrityPause *prometheus.CounterVec @@ -172,6 +175,13 @@ func NewControllerMetrics() *Metrics { } } +// SetTLSProfileSpec configures the TLS profile spec to use for the metrics +// server. When set, the server uses cipher suites and minimum TLS version +// from the given profile spec instead of the defaults. +func (m *Metrics) SetTLSProfileSpec(profile configv1.TLSProfileSpec) { + m.tlsProfileSpec = &profile +} + // Register iterates over all available Metrics and registers them. func (m *Metrics) Register() error { for name, collector := range map[string]prometheus.Collector{ @@ -204,6 +214,14 @@ func (m *Metrics) Start(ctx context.Context) error { NextProtos: []string{"http/1.1"}, } tlsConfig = libgocrypto.SecureTLSConfig(tlsConfig) + if m.tlsProfileSpec != nil { + tlsConfigFn, unsupported := tlspkg.NewTLSConfigFromProfile(*m.tlsProfileSpec) + if len(unsupported) > 0 { + m.log.Info("TLS profile contains ciphers unsupported by Go", "unsupported", unsupported) + } + tlsConfigFn(tlsConfig) + } + server := &http.Server{ Addr: ":8585", TLSConfig: tlsConfig, diff --git a/pkg/controller/metrics/metrics_test.go b/pkg/controller/metrics/metrics_test.go index 95ff9133c..80a17fe9f 100644 --- a/pkg/controller/metrics/metrics_test.go +++ b/pkg/controller/metrics/metrics_test.go @@ -20,11 +20,11 @@ import ( "errors" "testing" + configv1 "github.com/openshift/api/config/v1" + "github.com/openshift/file-integrity-operator/pkg/controller/metrics/metricsfakes" "github.com/prometheus/client_golang/prometheus" dto "github.com/prometheus/client_model/go" "github.com/stretchr/testify/require" - - "github.com/openshift/file-integrity-operator/pkg/controller/metrics/metricsfakes" ) var errTest = errors.New("") @@ -61,6 +61,23 @@ func TestRegisterMetrics(t *testing.T) { } } +func TestSetTLSProfileSpec(t *testing.T) { + t.Parallel() + + sut := NewControllerMetrics() + require.Nil(t, sut.tlsProfileSpec) + + profile := configv1.TLSProfileSpec{ + Ciphers: configv1.TLSProfiles[configv1.TLSProfileIntermediateType].Ciphers, + MinTLSVersion: configv1.VersionTLS12, + } + sut.SetTLSProfileSpec(profile) + + require.NotNil(t, sut.tlsProfileSpec) + require.Equal(t, configv1.VersionTLS12, sut.tlsProfileSpec.MinTLSVersion) + require.Equal(t, profile.Ciphers, sut.tlsProfileSpec.Ciphers) +} + func TestFileIntegrityMetrics(t *testing.T) { t.Parallel() diff --git a/tests/e2e/main_test.go b/tests/e2e/main_test.go index 4a4b90ba1..43b3c7e9b 100644 --- a/tests/e2e/main_test.go +++ b/tests/e2e/main_test.go @@ -1,10 +1,100 @@ package e2e import ( - "github.com/openshift/file-integrity-operator/tests/framework" "testing" + + configv1 "github.com/openshift/api/config/v1" + "github.com/openshift/file-integrity-operator/tests/framework" ) func TestMain(m *testing.M) { framework.MainEntry(m) } + +func TestOperatorHonorsClusterTLSProfile(t *testing.T) { + f := framework.Global + + // Fetch the cluster APIServer resource. + apiServer, err := f.GetClusterAPIServer() + if err != nil { + t.Fatalf("failed to get APIServer cluster resource: %s", err) + } + t.Logf("Original TLS adherence policy: %q", apiServer.Spec.TLSAdherence) + + // Skip if the cluster is older than OCP 4.22, which is the minimum + // version that supports the tlsAdherence field on the APIServer resource. + atLeast422, err := f.IsOCPVersionAtLeast(4, 22) + if err != nil { + t.Fatalf("failed to check cluster version: %s", err) + } + if !atLeast422 { + t.Skip("cluster is older than OCP 4.22, tlsAdherence is not supported") + } + + // Verify the metrics endpoint matches the current cluster TLS configuration. + expectedTLSVersion := f.GetExpectedMinTLSVersion(apiServer) + t.Logf("Expected minimum TLS version before change: %s", expectedTLSVersion) + if err := f.AssertMetricsEndpointMinTLSVersion(expectedTLSVersion); err != nil { + t.Fatalf("metrics endpoint TLS version check failed before change: %s", err) + } + + // Record the current operator pod UID so we can detect when it restarts. + operatorPods, err := f.GetOperatorPods() + if err != nil { + t.Fatalf("failed to get operator pods: %s", err) + } + if len(operatorPods) == 0 { + t.Fatal("no operator pods found") + } + originalPodUID := operatorPods[0].UID + t.Logf("Original operator pod UID: %s", originalPodUID) + + // Change the APIServer TLS configuration to strict adherence with the + // Modern profile (TLS 1.3) so we can verify the operator enforces a + // stricter TLS configuration when required. + t.Log("Updating APIServer to strict adherence with Modern TLS profile") + apiServer, err = f.GetClusterAPIServer() + if err != nil { + t.Fatalf("failed to get APIServer for update: %s", err) + } + apiServer.Spec.TLSAdherence = configv1.TLSAdherencePolicyStrictAllComponents + apiServer.Spec.TLSSecurityProfile = &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileModernType, + Modern: &configv1.ModernTLSProfile{}, + } + if err := f.Client.Update(t.Context(), apiServer); err != nil { + t.Fatalf("failed to update APIServer TLS configuration: %s", err) + } + + // Wait for the operator pod to restart. The SecurityProfileWatcher + // should detect the change and trigger a graceful shutdown. + t.Log("Waiting for operator pod to restart after TLS profile change") + if err := f.WaitForOperatorPodRestart(originalPodUID); err != nil { + t.Fatalf("operator pod did not restart after TLS profile change: %s", err) + } + + // Wait for the operator deployment to be fully available. + if err := f.WaitForDeployment("file-integrity-operator", 1, framework.RetryInterval, framework.Timeout); err != nil { + t.Fatalf("operator did not become ready after TLS profile change: %s", err) + } + + // Verify the metrics endpoint now uses the updated TLS version. + apiServer, err = f.GetClusterAPIServer() + if err != nil { + t.Fatalf("failed to get APIServer after update: %s", err) + } + expectedTLSVersion = f.GetExpectedMinTLSVersion(apiServer) + t.Logf("Expected minimum TLS version after change: %s", expectedTLSVersion) + if err := f.AssertMetricsEndpointMinTLSVersion(expectedTLSVersion); err != nil { + t.Fatalf("metrics endpoint TLS version check failed after change: %s", err) + } + + // Verify connections capped below the new minimum are rejected. This + // proves the floor was actually raised — without it, a server that + // silently ignores the profile still passes the positive check above + // because curl negotiates the highest mutually supported version. + t.Log("Verifying metrics endpoint rejects TLS 1.2 connections") + if err := f.AssertMetricsEndpointRejectsTLSVersion("1.2"); err != nil { + t.Fatalf("metrics endpoint accepted a TLS 1.2 connection despite Modern (TLS 1.3) profile: %s", err) + } +} diff --git a/tests/framework/constants.go b/tests/framework/constants.go new file mode 100644 index 000000000..9ae49a252 --- /dev/null +++ b/tests/framework/constants.go @@ -0,0 +1,9 @@ +package framework + +import "time" + +const ( + RetryInterval = time.Second * 5 + Timeout = time.Minute * 30 + RhcosContentFile = "ssg-rhcos4-ds.xml" +) diff --git a/tests/framework/framework.go b/tests/framework/framework.go index 95cdddd4e..d19b24545 100644 --- a/tests/framework/framework.go +++ b/tests/framework/framework.go @@ -17,7 +17,10 @@ import ( "github.com/go-logr/logr" machinev1 "github.com/openshift/api/machine/v1beta1" log "github.com/sirupsen/logrus" + "golang.org/x/net/context" extscheme "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/scheme" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/wait" cached "k8s.io/client-go/discovery/cached" @@ -321,3 +324,28 @@ func (f *Framework) setupLocalCommand() (*exec.Cmd, error) { localCmd.Env = append(localCmd.Env, fmt.Sprintf("%v=%v", WatchNamespaceEnvVar, watchNamespace)) return localCmd, nil } + +func (f *Framework) WaitForDeployment(name string, replicas int, retryInterval, timeout time.Duration) error { + err := wait.Poll(retryInterval, timeout, func() (done bool, err error) { + deployment, err := f.KubeClient.AppsV1().Deployments(f.OperatorNamespace).Get(context.TODO(), name, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + log.Printf("Waiting for availability of Deployment: %s in Namespace: %s \n", name, f.OperatorNamespace) + return false, nil + } + return false, err + } + + if int(deployment.Status.AvailableReplicas) >= replicas { + return true, nil + } + log.Printf("Waiting for full availability of %s deployment (%d/%d)\n", name, + deployment.Status.AvailableReplicas, replicas) + return false, nil + }) + if err != nil { + return err + } + log.Printf("Deployment available (%d/%d)\n", replicas, replicas) + return nil +} diff --git a/tests/framework/utils.go b/tests/framework/utils.go new file mode 100644 index 000000000..135ec7b97 --- /dev/null +++ b/tests/framework/utils.go @@ -0,0 +1,422 @@ +package framework + +import ( + "context" + "encoding/base64" + "fmt" + "log" + "os/exec" + "regexp" + "strings" + "time" + + configv1 "github.com/openshift/api/config/v1" + tlspkg "github.com/openshift/controller-runtime-common/pkg/tls" + "golang.org/x/mod/semver" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" +) + +// GetClusterAPIServer fetches the APIServer "cluster" resource. +func (f *Framework) GetClusterAPIServer() (*configv1.APIServer, error) { + apiServer := &configv1.APIServer{} + key := types.NamespacedName{Name: "cluster"} + if err := f.Client.Get(context.TODO(), key, apiServer); err != nil { + return nil, fmt.Errorf("failed to get APIServer cluster resource: %w", err) + } + return apiServer, nil +} + +// GetExpectedMinTLSVersion returns the expected minimum TLS version string +// (e.g., "TLSv1.2", "TLSv1.3") for the metrics endpoint based on the +// cluster's APIServer TLS configuration and adherence policy. +func (f *Framework) GetExpectedMinTLSVersion(apiServer *configv1.APIServer) string { + profile := extractTLSProfileForTest(apiServer) + spec, err := tlspkg.GetTLSProfileSpec(profile) + if err != nil { + // Fall back to Intermediate defaults if profile resolution fails. + spec = *configv1.TLSProfiles[configv1.TLSProfileIntermediateType] + } + switch spec.MinTLSVersion { + case configv1.VersionTLS10: + return "TLSv1.0" + case configv1.VersionTLS11: + return "TLSv1.1" + case configv1.VersionTLS12: + return "TLSv1.2" + case configv1.VersionTLS13: + return "TLSv1.3" + default: + return "TLSv1.2" + } +} + +// tlsVersionNumber maps a TLS version string (e.g. "TLSv1.2") to a numeric +// value for comparison. Higher values mean newer TLS versions. +var tlsVersionNumber = map[string]int{ + "TLSv1.0": 10, + "TLSv1.1": 11, + "TLSv1.2": 12, + "TLSv1.3": 13, +} + +// parseTLSVersionFromCurlOutput extracts the TLS version string from curl +// verbose output containing an "SSL connection using TLSvX.Y" line. +func parseTLSVersionFromCurlOutput(output string) string { + re := regexp.MustCompile(`TLSv1\.[0-3]`) + return re.FindString(output) +} + +// tlsVersionAtLeast returns true if actual >= minimum using the TLS version +// ordering. Both arguments should be strings like "TLSv1.2". +func tlsVersionAtLeast(actual, minimum string) bool { + a, aOK := tlsVersionNumber[actual] + m, mOK := tlsVersionNumber[minimum] + if !aOK || !mOK { + return false + } + return a >= m +} + +// AssertMetricsEndpointMinTLSVersion uses curl to connect to the metrics +// endpoint and verifies the negotiated TLS version is at least the expected +// minimum. The server may negotiate a higher version than the minimum (e.g. +// TLS 1.3 when the minimum is 1.2), which is correct behavior. +func (f *Framework) AssertMetricsEndpointMinTLSVersion(expectedMinTLSVersion string) error { + endpoint := fmt.Sprintf("https://metrics.%s.svc:8585/metrics-co", f.OperatorNamespace) + curlCMD := fmt.Sprintf("curl -vks %s 2>&1 | grep 'SSL connection'", endpoint) + + ocPath, err := exec.LookPath("oc") + if err != nil { + return fmt.Errorf("oc not found: %w", err) + } + + var lastErr error + timeouterr := wait.Poll(RetryInterval, Timeout, func() (bool, error) { + // #nosec G204 + cmd := exec.Command(ocPath, + "run", "--rm", "-i", "--restart=Never", + "--image=registry.fedoraproject.org/fedora-minimal:latest", + "-n", f.OperatorNamespace, "tls-version-test", + "--", "bash", "-c", curlCMD, + ) + out, err := cmd.CombinedOutput() + if err != nil { + lastErr = fmt.Errorf("curl command failed: %v, output: %s", err, string(out)) + log.Printf("%v... retrying\n", lastErr) + return false, nil + } + + output := string(out) + actual := parseTLSVersionFromCurlOutput(output) + if actual == "" { + lastErr = fmt.Errorf("could not parse TLS version from output: %s", output) + log.Printf("%v... retrying\n", lastErr) + return false, nil + } + if !tlsVersionAtLeast(actual, expectedMinTLSVersion) { + lastErr = fmt.Errorf("negotiated TLS version %s is below minimum %s", actual, expectedMinTLSVersion) + log.Printf("%v... retrying\n", lastErr) + return false, nil + } + log.Printf("metrics endpoint using %s (minimum: %s)\n", actual, expectedMinTLSVersion) + return true, nil + }) + if timeouterr != nil { + if lastErr != nil { + return lastErr + } + return timeouterr + } + return nil +} + +// AssertResultServerMinTLSVersion verifies that the result server created for +// the given scan uses the expected minimum TLS version. It fetches client +// certificates from the scan's Kubernetes secret and uses curl with mTLS to +// connect to the result server endpoint. +func (f *Framework) AssertResultServerMinTLSVersion(scanName, expectedMinTLSVersion string) error { + ocPath, err := exec.LookPath("oc") + if err != nil { + return fmt.Errorf("oc not found: %w", err) + } + + var lastErr error + timeouterr := wait.Poll(RetryInterval, Timeout, func() (bool, error) { + clientCertSecret, err := f.KubeClient.CoreV1().Secrets(f.OperatorNamespace).Get( + context.TODO(), "result-client-cert-"+scanName, metav1.GetOptions{}, + ) + if err != nil { + lastErr = fmt.Errorf("failed to get client cert secret: %v", err) + log.Printf("%v... retrying\n", lastErr) + return false, nil + } + + certB64 := base64.StdEncoding.EncodeToString(clientCertSecret.Data["tls.crt"]) + keyB64 := base64.StdEncoding.EncodeToString(clientCertSecret.Data["tls.key"]) + + endpoint := fmt.Sprintf("https://%s-rs:8443/", scanName) + curlCMD := fmt.Sprintf( + "echo '%s' | base64 -d > /tmp/client.crt && "+ + "echo '%s' | base64 -d > /tmp/client.key && "+ + "curl -vks --cert /tmp/client.crt --key /tmp/client.key %s 2>&1 | grep 'SSL connection'", + certB64, keyB64, endpoint, + ) + + // #nosec G204 + cmd := exec.Command(ocPath, + "run", "--rm", "-i", "--restart=Never", + "--image=registry.fedoraproject.org/fedora-minimal:latest", + "-n", f.OperatorNamespace, "rs-tls-version-test", + "--", "bash", "-c", curlCMD, + ) + out, err := cmd.CombinedOutput() + if err != nil { + lastErr = fmt.Errorf("curl command failed: %v, output: %s", err, string(out)) + log.Printf("%v... retrying\n", lastErr) + return false, nil + } + + output := string(out) + actual := parseTLSVersionFromCurlOutput(output) + if actual == "" { + lastErr = fmt.Errorf("could not parse TLS version from result server output: %s", output) + log.Printf("%v... retrying\n", lastErr) + return false, nil + } + if !tlsVersionAtLeast(actual, expectedMinTLSVersion) { + lastErr = fmt.Errorf("result server negotiated TLS version %s is below minimum %s", actual, expectedMinTLSVersion) + log.Printf("%v... retrying\n", lastErr) + return false, nil + } + log.Printf("result server using %s (minimum: %s)\n", actual, expectedMinTLSVersion) + return true, nil + }) + if timeouterr != nil { + if lastErr != nil { + return lastErr + } + return timeouterr + } + return nil +} + +// AssertMetricsEndpointRejectsTLSVersion verifies that the metrics endpoint +// rejects connections limited to the given TLS version. This is the inverse of +// AssertMetricsEndpointMinTLSVersion: it proves the server's floor is actually +// above the given version by confirming the handshake fails. +func (f *Framework) AssertMetricsEndpointRejectsTLSVersion(rejectedTLSVersion string) error { + endpoint := fmt.Sprintf("https://metrics.%s.svc:8585/metrics-co", f.OperatorNamespace) + curlCMD := fmt.Sprintf("curl -vks --tls-max %s %s 2>&1", rejectedTLSVersion, endpoint) + + ocPath, err := exec.LookPath("oc") + if err != nil { + return fmt.Errorf("oc not found: %w", err) + } + + var lastErr error + timeouterr := wait.Poll(RetryInterval, Timeout, func() (bool, error) { + // #nosec G204 + cmd := exec.Command(ocPath, + "run", "--rm", "-i", "--restart=Never", + "--image=registry.fedoraproject.org/fedora-minimal:latest", + "-n", f.OperatorNamespace, "tls-reject-test", + "--", "bash", "-c", curlCMD, + ) + out, err := cmd.CombinedOutput() + output := string(out) + + if err == nil && !strings.Contains(output, "SSL") && !strings.Contains(output, "alert") { + lastErr = fmt.Errorf("expected connection with --tls-max %s to be rejected, but it succeeded: %s", rejectedTLSVersion, output) + log.Printf("%v... retrying\n", lastErr) + return false, nil + } + log.Printf("metrics endpoint correctly rejected connection capped at %s\n", rejectedTLSVersion) + return true, nil + }) + if timeouterr != nil { + if lastErr != nil { + return lastErr + } + return timeouterr + } + return nil +} + +// AssertResultServerRejectsTLSVersion verifies that the result server for the +// given scan rejects connections limited to the given TLS version. This proves +// the server's TLS floor is above the capped version by confirming the +// handshake fails. +func (f *Framework) AssertResultServerRejectsTLSVersion(scanName, rejectedTLSVersion string) error { + ocPath, err := exec.LookPath("oc") + if err != nil { + return fmt.Errorf("oc not found: %w", err) + } + + var lastErr error + timeouterr := wait.Poll(RetryInterval, Timeout, func() (bool, error) { + clientCertSecret, err := f.KubeClient.CoreV1().Secrets(f.OperatorNamespace).Get( + context.TODO(), "result-client-cert-"+scanName, metav1.GetOptions{}, + ) + if err != nil { + lastErr = fmt.Errorf("failed to get client cert secret: %v", err) + log.Printf("%v... retrying\n", lastErr) + return false, nil + } + + certB64 := base64.StdEncoding.EncodeToString(clientCertSecret.Data["tls.crt"]) + keyB64 := base64.StdEncoding.EncodeToString(clientCertSecret.Data["tls.key"]) + + endpoint := fmt.Sprintf("https://%s-rs:8443/", scanName) + curlCMD := fmt.Sprintf( + "echo '%s' | base64 -d > /tmp/client.crt && "+ + "echo '%s' | base64 -d > /tmp/client.key && "+ + "curl -vks --tls-max %s --cert /tmp/client.crt --key /tmp/client.key %s 2>&1", + certB64, keyB64, rejectedTLSVersion, endpoint, + ) + + // #nosec G204 + cmd := exec.Command(ocPath, + "run", "--rm", "-i", "--restart=Never", + "--image=registry.fedoraproject.org/fedora-minimal:latest", + "-n", f.OperatorNamespace, "rs-tls-reject-test", + "--", "bash", "-c", curlCMD, + ) + out, err := cmd.CombinedOutput() + output := string(out) + + if err == nil && !strings.Contains(output, "SSL") && !strings.Contains(output, "alert") { + lastErr = fmt.Errorf("expected result server connection with --tls-max %s to be rejected, but it succeeded: %s", rejectedTLSVersion, output) + log.Printf("%v... retrying\n", lastErr) + return false, nil + } + log.Printf("result server correctly rejected connection capped at %s\n", rejectedTLSVersion) + return true, nil + }) + if timeouterr != nil { + if lastErr != nil { + return lastErr + } + return timeouterr + } + return nil +} + +// WaitForNodesToBeSchedulable waits until all nodes in the cluster are +// schedulable and ready. This is useful after changing the APIServer TLS +// profile, which triggers a kube-apiserver rollout that temporarily cordons +// nodes. +func (f *Framework) WaitForNodesToBeSchedulable() error { + var lastErr error + timeouterr := wait.Poll(RetryInterval, 20*time.Minute, func() (bool, error) { + nodes, err := f.KubeClient.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + lastErr = fmt.Errorf("failed to list nodes: %v", err) + log.Printf("%v... retrying\n", lastErr) + return false, nil + } + for _, node := range nodes.Items { + if node.Spec.Unschedulable { + lastErr = fmt.Errorf("node %s is unschedulable", node.Name) + log.Printf("%v... retrying\n", lastErr) + return false, nil + } + ready := false + for _, cond := range node.Status.Conditions { + if cond.Type == corev1.NodeReady && cond.Status == corev1.ConditionTrue { + ready = true + break + } + } + if !ready { + lastErr = fmt.Errorf("node %s is not ready", node.Name) + log.Printf("%v... retrying\n", lastErr) + return false, nil + } + } + return true, nil + }) + if timeouterr != nil { + if lastErr != nil { + return lastErr + } + return timeouterr + } + return nil +} + +// extractTLSProfileForTest mirrors the operator's logic for determining +// which TLS profile to use based on the APIServer adherence policy. +func extractTLSProfileForTest(apiServer *configv1.APIServer) *configv1.TLSSecurityProfile { + switch apiServer.Spec.TLSAdherence { + case configv1.TLSAdherencePolicyStrictAllComponents: + if apiServer.Spec.TLSSecurityProfile != nil { + return apiServer.Spec.TLSSecurityProfile + } + return &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileIntermediateType, + } + default: + // When adherence is not strict, the operator uses secure defaults + // (Intermediate profile from library-go's SecureTLSConfig). + return &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileIntermediateType, + } + } +} + +// GetOperatorPods returns the operator pods in the operator namespace. +func (f *Framework) GetOperatorPods() ([]corev1.Pod, error) { + podList, err := f.KubeClient.CoreV1().Pods(f.OperatorNamespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list pods: %w", err) + } + var operatorPods []corev1.Pod + for _, pod := range podList.Items { + if strings.Contains(pod.GetName(), "file-integrity-operator") { + operatorPods = append(operatorPods, pod) + } + } + return operatorPods, nil +} + +// WaitForOperatorPodRestart waits until the operator pod has a different UID +// than the one provided, indicating the pod has been restarted. +func (f *Framework) WaitForOperatorPodRestart(originalPodUID types.UID) error { + return wait.Poll(RetryInterval, Timeout, func() (bool, error) { + pods, err := f.GetOperatorPods() + if err != nil { + log.Printf("Error getting operator pods: %v... retrying\n", err) + return false, nil + } + for _, pod := range pods { + if pod.UID != originalPodUID && pod.Status.Phase == corev1.PodRunning { + log.Printf("Operator pod restarted: new UID %s\n", pod.UID) + return true, nil + } + } + log.Println("Waiting for operator pod to restart...") + return false, nil + }) +} + +// IsOCPVersionAtLeast checks whether the cluster is running at least the +// specified OCP version (e.g. 4, 22 for OCP 4.22). Returns false if the +// ClusterVersion resource cannot be fetched or has no history. +func (f *Framework) IsOCPVersionAtLeast(major, minor int) (bool, error) { + clusterVersion := &configv1.ClusterVersion{} + key := types.NamespacedName{Name: "version"} + if err := f.Client.Get(context.TODO(), key, clusterVersion); err != nil { + return false, fmt.Errorf("failed to get ClusterVersion: %w", err) + } + if len(clusterVersion.Status.History) == 0 { + return false, fmt.Errorf("ClusterVersion has no history entries") + } + version := clusterVersion.Status.History[0].Version + if !semver.IsValid("v" + version) { + return false, fmt.Errorf("unexpected version format: %s", version) + } + return semver.Compare("v"+version, fmt.Sprintf("v%d.%d.0", major, minor)) >= 0, nil +} diff --git a/vendor/github.com/go-logr/logr/testr/testr.go b/vendor/github.com/go-logr/logr/testr/testr.go new file mode 100644 index 000000000..28168e0ae --- /dev/null +++ b/vendor/github.com/go-logr/logr/testr/testr.go @@ -0,0 +1,167 @@ +/* +Copyright 2019 The logr Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package testr provides support for using logr in tests. +package testr + +import ( + "testing" + + "github.com/go-logr/logr" + "github.com/go-logr/logr/funcr" +) + +// New returns a logr.Logger that prints through a testing.T object. +// Info logs are only enabled at V(0). +func New(t *testing.T) logr.Logger { + return NewWithOptions(t, Options{}) +} + +// Options carries parameters which influence the way logs are generated. +type Options struct { + // LogTimestamp tells the logger to add a "ts" key to log + // lines. This has some overhead, so some users might not want + // it. + LogTimestamp bool + + // Verbosity tells the logger which V logs to be write. + // Higher values enable more logs. + Verbosity int +} + +// NewWithOptions returns a logr.Logger that prints through a testing.T object. +// In contrast to the simpler New, output formatting can be configured. +func NewWithOptions(t *testing.T, opts Options) logr.Logger { + l := &testlogger{ + testloggerInterface: newLoggerInterfaceWithOptions(t, opts), + } + return logr.New(l) +} + +// TestingT is an interface wrapper around testing.T, testing.B and testing.F. +type TestingT interface { + Helper() + Log(args ...any) +} + +// NewWithInterface returns a logr.Logger that prints through a +// TestingT object. +// In contrast to the simpler New, output formatting can be configured. +func NewWithInterface(t TestingT, opts Options) logr.Logger { + l := newLoggerInterfaceWithOptions(t, opts) + return logr.New(&l) +} + +func newLoggerInterfaceWithOptions(t TestingT, opts Options) testloggerInterface { + return testloggerInterface{ + t: t, + Formatter: funcr.NewFormatter(funcr.Options{ + LogTimestamp: opts.LogTimestamp, + Verbosity: opts.Verbosity, + }), + } +} + +// Underlier exposes access to the underlying testing.T instance. Since +// callers only have a logr.Logger, they have to know which +// implementation is in use, so this interface is less of an +// abstraction and more of a way to test type conversion. +type Underlier interface { + GetUnderlying() *testing.T +} + +// UnderlierInterface exposes access to the underlying TestingT instance. Since +// callers only have a logr.Logger, they have to know which +// implementation is in use, so this interface is less of an +// abstraction and more of a way to test type conversion. +type UnderlierInterface interface { + GetUnderlying() TestingT +} + +// Info logging implementation shared between testLogger and testLoggerInterface. +func logInfo(t TestingT, formatInfo func(int, string, []any) (string, string), level int, msg string, kvList ...any) { + prefix, args := formatInfo(level, msg, kvList) + t.Helper() + if prefix != "" { + args = prefix + ": " + args + } + t.Log(args) +} + +// Error logging implementation shared between testLogger and testLoggerInterface. +func logError(t TestingT, formatError func(error, string, []any) (string, string), err error, msg string, kvList ...any) { + prefix, args := formatError(err, msg, kvList) + t.Helper() + if prefix != "" { + args = prefix + ": " + args + } + t.Log(args) +} + +// This type exists to wrap and modify the method-set of testloggerInterface. +// In particular, it changes the GetUnderlying() method. +type testlogger struct { + testloggerInterface +} + +func (l testlogger) GetUnderlying() *testing.T { + // This method is defined on testlogger, so the only type this could + // possibly be is testing.T, even though that's not guaranteed by the type + // system itself. + return l.t.(*testing.T) //nolint:forcetypeassert +} + +type testloggerInterface struct { + funcr.Formatter + t TestingT +} + +func (l testloggerInterface) WithName(name string) logr.LogSink { + l.AddName(name) // via Formatter + return &l +} + +func (l testloggerInterface) WithValues(kvList ...any) logr.LogSink { + l.AddValues(kvList) // via Formatter + return &l +} + +func (l testloggerInterface) GetCallStackHelper() func() { + return l.t.Helper +} + +func (l testloggerInterface) Info(level int, msg string, kvList ...any) { + l.t.Helper() + logInfo(l.t, l.FormatInfo, level, msg, kvList...) +} + +func (l testloggerInterface) Error(err error, msg string, kvList ...any) { + l.t.Helper() + logError(l.t, l.FormatError, err, msg, kvList...) +} + +func (l testloggerInterface) GetUnderlying() TestingT { + return l.t +} + +// Assert conformance to the interfaces. +var _ logr.LogSink = &testlogger{} +var _ logr.CallStackHelperLogSink = &testlogger{} +var _ Underlier = &testlogger{} + +var _ logr.LogSink = &testloggerInterface{} +var _ logr.CallStackHelperLogSink = &testloggerInterface{} +var _ UnderlierInterface = &testloggerInterface{} diff --git a/vendor/github.com/openshift/controller-runtime-common/LICENSE b/vendor/github.com/openshift/controller-runtime-common/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/vendor/github.com/openshift/controller-runtime-common/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/github.com/openshift/controller-runtime-common/pkg/tls/controller.go b/vendor/github.com/openshift/controller-runtime-common/pkg/tls/controller.go new file mode 100644 index 000000000..41ef2f454 --- /dev/null +++ b/vendor/github.com/openshift/controller-runtime-common/pkg/tls/controller.go @@ -0,0 +1,164 @@ +/* +Copyright 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tls + +import ( + "context" + "fmt" + "reflect" + + "github.com/go-logr/logr" + configv1 "github.com/openshift/api/config/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// SecurityProfileWatcher watches the APIServer object for TLS profile changes +// and triggers a graceful shutdown when the profile changes. +type SecurityProfileWatcher struct { + client.Client + + // InitialTLSProfileSpec is the TLS profile spec that was configured when the operator started. + InitialTLSProfileSpec configv1.TLSProfileSpec + + // InitialTLSAdherencePolicy is the TLS adherence policy that was configured when the operator started. + InitialTLSAdherencePolicy configv1.TLSAdherencePolicy + + // OnProfileChange is a function that will be called when the TLS profile changes. + // It receives the reconcile context, old and new TLS profile specs. + // This allows the caller to make decisions based on the actual profile changes. + // + // The most common use case for this callback is + // to trigger a graceful shutdown of the operator + // to make it pick up the new configuration. + // + // Example: + // + // // Create a context that can be cancelled when there is a need to shut down the manager. + // ctx, cancel := context.WithCancel(ctrl.SetupSignalHandler()) + // defer cancel() + // + // watcher := &SecurityProfileWatcher{ + // OnProfileChange: func(ctx context.Context, old, new configv1.TLSProfileSpec) { + // logger.Infof("TLS profile has changed, initiating a shutdown to reload it. %q: %+v, %q: %+v", + // "old profile", old, + // "new profile", new, + // ) + // // Cancel the outer context to trigger a graceful shutdown of the manager. + // cancel() + // }, + // } + OnProfileChange func(ctx context.Context, oldTLSProfileSpec, newTLSProfileSpec configv1.TLSProfileSpec) + + // OnAdherencePolicyChange is a function that will be called when the TLS adherence policy changes. + OnAdherencePolicyChange func(ctx context.Context, oldTLSAdherencePolicy, newTLSAdherencePolicy configv1.TLSAdherencePolicy) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *SecurityProfileWatcher) SetupWithManager(mgr ctrl.Manager) error { + if err := ctrl.NewControllerManagedBy(mgr). + Named("tlssecurityprofilewatcher"). + WithOptions(controller.Options{NeedLeaderElection: ptr.To(false)}). + For(&configv1.APIServer{}, builder.WithPredicates( + predicate.Funcs{ + // Only watch the "cluster" APIServer object. + CreateFunc: func(e event.CreateEvent) bool { + return e.Object.GetName() == APIServerName + }, + UpdateFunc: func(e event.UpdateEvent) bool { + return e.ObjectNew.GetName() == APIServerName + }, + DeleteFunc: func(e event.DeleteEvent) bool { + return e.Object.GetName() == APIServerName + }, + GenericFunc: func(e event.GenericEvent) bool { + return e.Object.GetName() == APIServerName + }, + }, + )). + // Override the default log constructor as it makes the logs very chatty. + WithLogConstructor(func(_ *reconcile.Request) logr.Logger { + return mgr.GetLogger().WithValues( + "controller", "tlssecurityprofilewatcher", + ) + }). + Complete(r); err != nil { + return fmt.Errorf("could not set up controller for TLS security profile watcher: %w", err) + } + + return nil +} + +// Reconcile watches for changes to the APIServer TLS profile and triggers a shutdown +// when the profile changes from the initial configuration. +func (r *SecurityProfileWatcher) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx, "name", req.Name) + + logger.V(1).Info("Reconciling APIServer TLS profile") + defer logger.V(1).Info("Finished reconciling APIServer TLS profile") + + // Fetch the APIServer object. + apiServer := &configv1.APIServer{} + if err := r.Get(ctx, req.NamespacedName, apiServer); err != nil { + if apierrors.IsNotFound(err) { + // If the APIServer object is not found, we don't need to do anything. + // This could happen if the object was deleted. + return ctrl.Result{}, nil + } + + return ctrl.Result{}, fmt.Errorf("failed to get APIServer %s: %w", req.NamespacedName.String(), err) + } + + // Get the current TLS profile spec. + currentTLSProfileSpec, err := GetTLSProfileSpec(apiServer.Spec.TLSSecurityProfile) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get TLS profile from APIServer %s: %w", req.NamespacedName.String(), err) + } + + // Compare the current TLS profile spec with the initial one. + if tlsProfileChanged := !reflect.DeepEqual(r.InitialTLSProfileSpec, currentTLSProfileSpec); tlsProfileChanged { + // TLS profile has changed, invoke the callback if it is set. + if r.OnProfileChange != nil { + r.OnProfileChange(ctx, r.InitialTLSProfileSpec, currentTLSProfileSpec) + } + + // Persist the new profile for future change detection. + r.InitialTLSProfileSpec = currentTLSProfileSpec + } + + // Compare the current TLS adherence policy with the initial one. + if tlsAdherencePolicyChanged := r.InitialTLSAdherencePolicy != apiServer.Spec.TLSAdherence; tlsAdherencePolicyChanged { + // TLS adherence policy has changed, invoke the callback if it is set. + if r.OnAdherencePolicyChange != nil { + r.OnAdherencePolicyChange(ctx, r.InitialTLSAdherencePolicy, apiServer.Spec.TLSAdherence) + } + + // Persist the new adherence policy for future change detection. + r.InitialTLSAdherencePolicy = apiServer.Spec.TLSAdherence + } + + // No need to requeue, as the callback will handle further actions. + return ctrl.Result{}, nil +} diff --git a/vendor/github.com/openshift/controller-runtime-common/pkg/tls/tls.go b/vendor/github.com/openshift/controller-runtime-common/pkg/tls/tls.go new file mode 100644 index 000000000..5b3b90bce --- /dev/null +++ b/vendor/github.com/openshift/controller-runtime-common/pkg/tls/tls.go @@ -0,0 +1,213 @@ +/* +Copyright 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package tls provides utilities for working with OpenShift TLS profiles. +package tls + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + + configv1 "github.com/openshift/api/config/v1" + libgocrypto "github.com/openshift/library-go/pkg/crypto" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // APIServerName is the name of the APIServer resource in the cluster. + APIServerName = "cluster" +) + +var ( + // ErrCustomProfileNil is returned when a custom TLS profile is specified but the Custom field is nil. + ErrCustomProfileNil = errors.New("custom TLS profile specified but Custom field is nil") + + // DefaultTLSCiphers are the default TLS ciphers for API servers. + DefaultTLSCiphers = configv1.TLSProfiles[configv1.TLSProfileIntermediateType].Ciphers //nolint:gochecknoglobals + // DefaultMinTLSVersion is the default minimum TLS version for API servers. + DefaultMinTLSVersion = configv1.TLSProfiles[configv1.TLSProfileIntermediateType].MinTLSVersion //nolint:gochecknoglobals + + // HTTP2NextProtos are the ALPN protocols advertised when HTTP/2 is enabled, + // with HTTP/1.1 fallback. + HTTP2NextProtos = []string{"h2", "http/1.1"} //nolint:gochecknoglobals + + // HTTP1NextProtos are the ALPN protocols advertised when HTTP/2 is disabled, + // restricting negotiation to HTTP/1.1 only. This provides defense-in-depth + // against HTTP/2 Rapid Reset (CVE-2023-44487, CVE-2023-39325) alongside + // the primary fixes in Go 1.21.3+ and golang.org/x/net v0.17.0+. + HTTP1NextProtos = []string{"http/1.1"} //nolint:gochecknoglobals +) + +// FetchAPIServerTLSProfile fetches the TLS profile spec configured in APIServer. +// If no profile is configured, the default profile is returned. +func FetchAPIServerTLSProfile(ctx context.Context, k8sClient client.Client) (configv1.TLSProfileSpec, error) { + apiServer := &configv1.APIServer{} + key := client.ObjectKey{Name: APIServerName} + + if err := k8sClient.Get(ctx, key, apiServer); err != nil { + return configv1.TLSProfileSpec{}, fmt.Errorf("failed to get APIServer %q: %w", key.String(), err) + } + + profile, err := GetTLSProfileSpec(apiServer.Spec.TLSSecurityProfile) + if err != nil { + return configv1.TLSProfileSpec{}, fmt.Errorf("failed to get TLS profile from APIServer %q: %w", key.String(), err) + } + + return profile, nil +} + +// FetchAPIServerTLSAdherencePolicy fetches the TLS adherence policy configured in APIServer. +// If no policy is configured, the default policy is returned. +func FetchAPIServerTLSAdherencePolicy(ctx context.Context, k8sClient client.Client) (configv1.TLSAdherencePolicy, error) { + apiServer := &configv1.APIServer{} + key := client.ObjectKey{Name: APIServerName} + + if err := k8sClient.Get(ctx, key, apiServer); err != nil { + return configv1.TLSAdherencePolicyNoOpinion, fmt.Errorf("failed to get APIServer %q: %w", key.String(), err) + } + + return apiServer.Spec.TLSAdherence, nil +} + +// GetTLSProfileSpec returns TLSProfileSpec for the given profile. +// If no profile is configured, the default profile is returned. +func GetTLSProfileSpec(profile *configv1.TLSSecurityProfile) (configv1.TLSProfileSpec, error) { + // Define the default profile (at the time of writing, this is the intermediate profile). + defaultProfile := *configv1.TLSProfiles[configv1.TLSProfileIntermediateType] + // If the profile is nil or the type is empty, return the default profile. + if profile == nil || profile.Type == "" { + return defaultProfile, nil + } + + // Get the profile type. + profileType := profile.Type + + // If the profile type is not custom, return the profile from the map. + if profileType != configv1.TLSProfileCustomType { + if tlsConfig, ok := configv1.TLSProfiles[profileType]; ok { + return *tlsConfig, nil + } + + // If the profile type is not found, return the default profile. + return defaultProfile, nil + } + + if profile.Custom == nil { + // If the custom profile is nil, return an error. + return configv1.TLSProfileSpec{}, ErrCustomProfileNil + } + + // Return the custom profile spec. + return profile.Custom.TLSProfileSpec, nil +} + +// NewTLSConfigFromProfile returns a function that configures a tls.Config based on the provided TLSProfileSpec, +// along with any cipher names from the profile that are not supported by the library-go crypto package. +// The returned function is intended to be used with controller-runtime's TLSOpts. +// +// Note: CipherSuites are only set when MinVersion is below TLS 1.3, as Go's TLS 1.3 implementation +// does not allow configuring cipher suites - all TLS 1.3 ciphers are always enabled. +// See: https://github.com/golang/go/issues/29349 +func NewTLSConfigFromProfile(profile configv1.TLSProfileSpec) (tlsConfig func(*tls.Config), unsupported []string) { + minVersion := libgocrypto.TLSVersionOrDie(string(profile.MinTLSVersion)) + cipherSuites, unsupportedCiphers := cipherCodes(profile.Ciphers) + curvePrefs, unsupportedGroups := libgocrypto.TLSGroupsToCurveIDs(profile.Groups) + + unsupported = unsupportedCiphers + for _, g := range unsupportedGroups { + unsupported = append(unsupported, string(g)) + } + + return func(tlsConf *tls.Config) { + tlsConf.MinVersion = minVersion + if len(curvePrefs) > 0 { + tlsConf.CurvePreferences = curvePrefs + } + + // TLS 1.3 cipher suites are not configurable in Go (https://github.com/golang/go/issues/29349), so only set CipherSuites accordingly. + // TODO: revisit this once we get an answer on the best way to handle this here: + // https://docs.google.com/document/d/1cMc9E8psHfnoK06ntR8kHSWB8d3rMtmldhnmM4nImjs/edit?disco=AAABu_nPcYg + if minVersion != tls.VersionTLS13 { + tlsConf.CipherSuites = cipherSuites + } + }, unsupported +} + +// SetNextProtos returns a TLS configuration function that sets the ALPN +// protocol negotiation list on a tls.Config. Empty strings are silently +// ignored, which allows conditional protocol inclusion. +// The returned function is intended to be used with controller-runtime's TLSOpts. +// +// Example: +// +// // Disable HTTP/2: +// openshifttls.SetNextProtos("http/1.1") +// +// // Enable HTTP/2 with fallback: +// openshifttls.SetNextProtos("h2", "http/1.1") +// +// // Using the well-known protocol lists: +// openshifttls.SetNextProtos(openshifttls.HTTP1NextProtos...) +// openshifttls.SetNextProtos(openshifttls.HTTP2NextProtos...) +func SetNextProtos(protos ...string) func(*tls.Config) { + var p []string + for _, proto := range protos { + if proto != "" { + p = append(p, proto) + } + } + return func(c *tls.Config) { + c.NextProtos = p + } +} + +// cipherCode returns the TLS cipher code for an OpenSSL or IANA cipher name. +// Returns 0 if the cipher is not supported. +func cipherCode(cipher string) uint16 { + // First try as IANA name directly. + if code, err := libgocrypto.CipherSuite(cipher); err == nil { + return code + } + + // Try converting from OpenSSL name to IANA name. + ianaCiphers := libgocrypto.OpenSSLToIANACipherSuites([]string{cipher}) + if len(ianaCiphers) == 1 { + if code, err := libgocrypto.CipherSuite(ianaCiphers[0]); err == nil { + return code + } + } + + // Return 0 if the cipher is not supported. + return 0 +} + +// cipherCodes converts a list of cipher names (OpenSSL or IANA format) to their uint16 codes. +// Returns the converted codes and a list of any unsupported cipher names. +func cipherCodes(ciphers []string) (codes []uint16, unsupportedCiphers []string) { + for _, cipher := range ciphers { + code := cipherCode(cipher) + if code == 0 { + unsupportedCiphers = append(unsupportedCiphers, cipher) + continue + } + + codes = append(codes, code) + } + + return codes, unsupportedCiphers +} diff --git a/vendor/modules.txt b/vendor/modules.txt index cfb123fbe..53fb19413 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -309,6 +309,7 @@ github.com/fxamacker/cbor/v2 github.com/go-logr/logr github.com/go-logr/logr/funcr github.com/go-logr/logr/slogr +github.com/go-logr/logr/testr # github.com/go-logr/stdr v1.2.2 ## explicit; go 1.16 github.com/go-logr/stdr @@ -649,6 +650,9 @@ github.com/openshift/api/config/v1 github.com/openshift/api/machine/v1beta1 github.com/openshift/api/machineconfiguration github.com/openshift/api/machineconfiguration/v1 +# github.com/openshift/controller-runtime-common v0.0.0-20260813135806-e1187ec555fc +## explicit; go 1.26.0 +github.com/openshift/controller-runtime-common/pkg/tls # github.com/openshift/library-go v0.0.0-20260807194649-ee0a87843dda ## explicit; go 1.26.0 github.com/openshift/library-go/pkg/crypto diff --git a/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/client.go b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/client.go index 2a07bd40b..accf917ca 100644 --- a/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/client.go +++ b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/client.go @@ -67,7 +67,6 @@ import ( clientgoapplyconfigurations "k8s.io/client-go/applyconfigurations" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/testing" - "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/apiutil" @@ -634,12 +633,12 @@ func (c *fakeClient) Create(ctx context.Context, obj client.Object, opts ...clie return err } + var generateNameBase string if accessor.GetName() == "" && accessor.GetGenerateName() != "" { - base := accessor.GetGenerateName() - if len(base) > maxGeneratedNameLength { - base = base[:maxGeneratedNameLength] + generateNameBase = accessor.GetGenerateName() + if len(generateNameBase) > maxGeneratedNameLength { + generateNameBase = generateNameBase[:maxGeneratedNameLength] } - accessor.SetName(fmt.Sprintf("%s%s", base, utilrand.String(randomLength))) } // Ignore attempts to set deletion timestamp if !accessor.GetDeletionTimestamp().IsZero() { @@ -654,10 +653,21 @@ func (c *fakeClient) Create(ctx context.Context, obj client.Object, opts ...clie c.trackerWriteLock.Lock() defer c.trackerWriteLock.Unlock() - if err := c.tracker.Create(gvr, obj, accessor.GetNamespace(), *createOptions.AsCreateOptions()); err != nil { + const maxRetries = 7 + var createErr error + for range maxRetries { + if generateNameBase != "" { + accessor.SetName(fmt.Sprintf("%s%s", generateNameBase, utilrand.String(randomLength))) + } + createErr = c.tracker.Create(gvr, obj, accessor.GetNamespace(), *createOptions.AsCreateOptions()) + if createErr == nil || generateNameBase == "" || !apierrors.IsAlreadyExists(createErr) { + break + } + } + if createErr != nil { // The managed fields tracker sets gvk even on errors _ = ensureTypeMeta(obj, gvk) - return err + return createErr } if !c.returnManagedFields { @@ -1621,13 +1631,13 @@ func extractScale(obj client.Object) (*autoscalingv1.Scale, error) { func applyScale(obj client.Object, scale *autoscalingv1.Scale) error { switch obj := obj.(type) { case *appsv1.Deployment: - obj.Spec.Replicas = ptr.To(scale.Spec.Replicas) + obj.Spec.Replicas = new(scale.Spec.Replicas) case *appsv1.ReplicaSet: - obj.Spec.Replicas = ptr.To(scale.Spec.Replicas) + obj.Spec.Replicas = new(scale.Spec.Replicas) case *corev1.ReplicationController: - obj.Spec.Replicas = ptr.To(scale.Spec.Replicas) + obj.Spec.Replicas = new(scale.Spec.Replicas) case *appsv1.StatefulSet: - obj.Spec.Replicas = ptr.To(scale.Spec.Replicas) + obj.Spec.Replicas = new(scale.Spec.Replicas) default: // TODO: CRDs https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#scale-subresource return fmt.Errorf("unimplemented scale subresource for resource %T", obj)