Skip to content
Draft
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
1 change: 1 addition & 0 deletions controllers/workspace/devworkspace_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ type DevWorkspaceReconciler struct {
// +kubebuilder:rbac:groups=oauth.openshift.io,resources=oauthclients,verbs=get;list;watch;create;update;patch;delete;deletecollection
// +kubebuilder:rbac:groups=monitoring.coreos.com,resources=servicemonitors,verbs=get;create
// +kubebuilder:rbac:groups=config.openshift.io,resources=proxies,verbs=get,resourceNames=cluster
// +kubebuilder:rbac:groups=config.openshift.io,resources=apiservers,verbs=get,resourceNames=cluster
// +kubebuilder:rbac:groups=apps,resourceNames=devworkspace-controller,resources=deployments/finalizers,verbs=update
/////// Required permissions for workspace ServiceAccount
// +kubebuilder:rbac:groups="",resources=pods/exec,verbs=create
Expand Down
1 change: 1 addition & 0 deletions deploy/deployment/kubernetes/combined.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions deploy/deployment/openshift/combined.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions deploy/templates/components/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ rules:
resourceNames:
- cluster
resources:
- apiservers
- proxies
verbs:
- get
Expand Down
11 changes: 6 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ require (
github.com/google/gofuzz v1.2.0
github.com/google/uuid v1.6.0
github.com/kevinburke/ssh_config v1.2.0
github.com/onsi/ginkgo/v2 v2.27.4
github.com/onsi/gomega v1.39.0
github.com/openshift/api v0.0.0-20200205133042-34f0ec8dab87
github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1
github.com/openshift/api v0.0.0-20260317165824-54a3998d81eb
github.com/openshift/controller-runtime-common v0.0.0-20260428152732-64ee174f5e2e
github.com/operator-framework/operator-lib v0.11.0
github.com/prometheus/client_golang v1.23.2
github.com/robfig/cron/v3 v3.0.0
Expand Down Expand Up @@ -60,11 +61,10 @@ require (
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/cel-go v0.29.0 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
Expand All @@ -78,6 +78,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/openshift/library-go v0.0.0-20260213153706-03f1709971c5 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
Expand Down
161 changes: 12 additions & 149 deletions go.sum

Large diffs are not rendered by default.

24 changes: 22 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/devfile/devworkspace-operator/pkg/config"
"github.com/devfile/devworkspace-operator/pkg/infrastructure"
kubesync "github.com/devfile/devworkspace-operator/pkg/library/kubernetes"
"github.com/devfile/devworkspace-operator/pkg/tlssetup"
"github.com/devfile/devworkspace-operator/pkg/webhook"
"github.com/devfile/devworkspace-operator/version"

Expand Down Expand Up @@ -116,6 +117,13 @@ func main() {
setupLog.Error(err, "failed to initialized Kubernetes objects decoder")
}

serverTLS, err := tlssetup.BuildServerTLSOptions(
context.Background(), ctrl.GetConfigOrDie(), scheme, setupLog)
if err != nil {
setupLog.Error(err, "failed to build TLS options for servers")
os.Exit(1)
}

cacheFunc, err := cache.GetCacheFunc()
if err != nil {
setupLog.Error(err, "failed to set up objects cache")
Expand All @@ -128,9 +136,11 @@ func main() {
BindAddress: metricsAddr,
FilterProvider: filters.WithAuthenticationAndAuthorization,
SecureServing: true,
TLSOpts: serverTLS.TLSOpts,
},
WebhookServer: ctrl_webhook.NewServer(ctrl_webhook.Options{
Port: 9443,
Port: 9443,
TLSOpts: serverTLS.TLSOpts,
}),
HealthProbeBindAddress: ":6789",
LeaderElection: enableLeaderElection,
Expand Down Expand Up @@ -220,6 +230,16 @@ func main() {
setupLog.Error(err, "failed creating conversion webhook for DevWorkspaces v1alpha2")
}

// On OpenShift, watch cluster TLS profile and restart if it changes.
signalCtx := ctrl.SetupSignalHandler()
ctx, cancelCtx := context.WithCancel(signalCtx)
defer cancelCtx()

if err := tlssetup.RegisterSecurityProfileWatcher(mgr, serverTLS, cancelCtx, setupLog); err != nil {
setupLog.Error(err, "unable to set up TLS security profile watcher")
os.Exit(1)
}

// Setup health check
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
setupLog.Error(err, "Unable to set up health check")
Expand All @@ -233,7 +253,7 @@ func main() {
}

setupLog.Info("starting manager")
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
if err := mgr.Start(ctx); err != nil {
setupLog.Error(err, "problem running manager")
os.Exit(1)
}
Expand Down
141 changes: 141 additions & 0 deletions pkg/tlssetup/server_tls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
//
// Copyright (c) 2019-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 tlssetup builds TLS options for controller-runtime servers from the
// OpenShift APIServer TLS profile.
package tlssetup

import (
"context"
"crypto/tls"

"github.com/go-logr/logr"
configv1 "github.com/openshift/api/config/v1"
ostls "github.com/openshift/controller-runtime-common/pkg/tls"
k8sruntime "k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/manager"

"github.com/devfile/devworkspace-operator/pkg/infrastructure"
)

// ServerTLS holds TLS options and the initial APIServer profile/adherence policy for watching.
type ServerTLS struct {
TLSOpts []func(*tls.Config)
InitialTLSProfileSpec configv1.TLSProfileSpec
InitialTLSAdherencePolicy configv1.TLSAdherencePolicy
}

// ShouldHonorClusterTLSProfile returns true when the component must honor the cluster
// TLS profile. Unknown values return true for forward compatibility.
func ShouldHonorClusterTLSProfile(adherence configv1.TLSAdherencePolicy) bool {
switch adherence {
case "", configv1.TLSAdherencePolicyLegacyAdheringComponentsOnly:
return false
default:
// StrictAllComponents or unknown future value → honor the profile
return true
}
}

// BuildServerTLSOptions fetches TLS settings from the OpenShift API server.
// Only applies the cluster profile when the tlsAdherence policy requires it.
func BuildServerTLSOptions(ctx context.Context, cfg *rest.Config, scheme *k8sruntime.Scheme, log logr.Logger) (ServerTLS, error) {
var result ServerTLS

if !infrastructure.IsOpenShift() {
log.Info("Not running on OpenShift; using Go default TLS configuration")
return result, nil
}

bootstrapClient, err := client.New(cfg, client.Options{Scheme: scheme})
if err != nil {
log.Error(err, "Failed to create bootstrap client for TLS profile fetch; using Go default TLS configuration")
return result, nil
}

profile, err := ostls.FetchAPIServerTLSProfile(ctx, bootstrapClient)
if err != nil {
log.Error(err, "Failed to fetch TLS profile from APIServer; using Go default TLS configuration")
return result, nil
}

adherence, err := ostls.FetchAPIServerTLSAdherencePolicy(ctx, bootstrapClient)
if err != nil {
log.Error(err, "Failed to fetch TLS adherence policy from APIServer; using Go default TLS configuration")
return result, nil
}

result.InitialTLSProfileSpec = profile
result.InitialTLSAdherencePolicy = adherence

// Check if we should honor the cluster TLS profile
if !ShouldHonorClusterTLSProfile(adherence) {
log.Info("TLS adherence policy does not require strict adherence; using Go default TLS configuration",
"policy", adherence)
return result, nil
}

// Apply the cluster TLS profile
tlsConfigFn, unsupported := ostls.NewTLSConfigFromProfile(profile)
if len(unsupported) > 0 {
log.Info("TLS profile contains ciphers unsupported by Go; they will be ignored",
"unsupportedCiphers", unsupported)
}

result.TLSOpts = []func(*tls.Config){tlsConfigFn}

log.Info("Applying cluster TLS profile to metrics and webhook servers",
"minTLSVersion", profile.MinTLSVersion,
"cipherCount", len(profile.Ciphers),
"adherencePolicy", adherence)

return result, nil
}

// RegisterSecurityProfileWatcher watches the APIServer TLS profile and adherence policy.
// Calls onCancel to trigger restart when either changes. No-op on non-OpenShift.
func RegisterSecurityProfileWatcher(mgr manager.Manager, serverTLS ServerTLS, onCancel context.CancelFunc, log logr.Logger) error {
if !infrastructure.IsOpenShift() {
return nil
}

// Only set up the watcher if we successfully fetched the initial profile
if len(serverTLS.TLSOpts) == 0 {
log.Info("Skipping TLS profile watcher (profile not applied)")
return nil
}

watcher := &ostls.SecurityProfileWatcher{
Client: mgr.GetClient(),
InitialTLSProfileSpec: serverTLS.InitialTLSProfileSpec,
InitialTLSAdherencePolicy: serverTLS.InitialTLSAdherencePolicy,
OnProfileChange: func(_ context.Context, old, new configv1.TLSProfileSpec) {
log.Info("TLS security profile changed; initiating graceful restart",
"oldMinTLSVersion", old.MinTLSVersion,
"newMinTLSVersion", new.MinTLSVersion)
onCancel()
},
OnAdherencePolicyChange: func(_ context.Context, old, new configv1.TLSAdherencePolicy) {
log.Info("TLS adherence policy changed; initiating graceful restart",
"old", old,
"new", new)
onCancel()
},
}

return watcher.SetupWithManager(mgr)
}
93 changes: 93 additions & 0 deletions pkg/tlssetup/server_tls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//
// Copyright (c) 2019-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 tlssetup

import (
"testing"

configv1 "github.com/openshift/api/config/v1"
"sigs.k8s.io/controller-runtime/pkg/log/zap"

"github.com/devfile/devworkspace-operator/pkg/infrastructure"
)

func TestShouldHonorClusterTLSProfile(t *testing.T) {
tests := []struct {
name string
adherence configv1.TLSAdherencePolicy
expected bool
}{
{
name: "Empty policy should not honor cluster TLS profile",
adherence: "",
expected: false,
},
{
name: "NoOpinion policy should not honor cluster TLS profile",
adherence: configv1.TLSAdherencePolicyNoOpinion,
expected: false,
},
{
name: "LegacyAdheringComponentsOnly should not honor cluster TLS profile",
adherence: configv1.TLSAdherencePolicyLegacyAdheringComponentsOnly,
expected: false,
},
{
name: "StrictAllComponents should honor cluster TLS profile",
adherence: configv1.TLSAdherencePolicyStrictAllComponents,
expected: true,
},
{
name: "Unknown policy should honor cluster TLS profile for forward compatibility",
adherence: configv1.TLSAdherencePolicy("UnknownFuturePolicy"),
expected: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ShouldHonorClusterTLSProfile(tt.adherence)
if got != tt.expected {
t.Errorf("ShouldHonorClusterTLSProfile(%v) = %v, expected %v", tt.adherence, got, tt.expected)
}
})
}
}

func TestRegisterSecurityProfileWatcher_NonOpenShift(t *testing.T) {
infrastructure.InitializeForTesting(infrastructure.Kubernetes)
defer infrastructure.InitializeForTesting(infrastructure.OpenShiftv4)

log := zap.New(zap.UseDevMode(true))

// On non-OpenShift, should be a no-op and return nil
err := RegisterSecurityProfileWatcher(nil, ServerTLS{}, nil, log)
if err != nil {
t.Errorf("RegisterSecurityProfileWatcher() on Kubernetes should be no-op, got error = %v", err)
}
}

func TestRegisterSecurityProfileWatcher_NoTLSOpts(t *testing.T) {
infrastructure.InitializeForTesting(infrastructure.OpenShiftv4)
defer infrastructure.InitializeForTesting(infrastructure.Kubernetes)

log := zap.New(zap.UseDevMode(true))

// When TLSOpts is empty (profile not applied), should skip watcher setup and return nil
err := RegisterSecurityProfileWatcher(nil, ServerTLS{}, nil, log)
if err != nil {
t.Errorf("RegisterSecurityProfileWatcher() with empty TLSOpts should skip setup, got error = %v", err)
}
}
Loading
Loading