diff --git a/TLS_ADHERENCE_TEST_PLAN.md b/TLS_ADHERENCE_TEST_PLAN.md new file mode 100644 index 000000000..28c9a4353 --- /dev/null +++ b/TLS_ADHERENCE_TEST_PLAN.md @@ -0,0 +1,139 @@ +# TLS Adherence Feature Test Plan + +Tests that DevWorkspace Operator honors the cluster TLS profile when `tlsAdherence: StrictAllComponents` is set. + +## Prerequisites + +```bash +# Verify OpenShift cluster and DWO installation +oc get deployment -n openshift-operators devworkspace-controller-manager +oc get deployment -n openshift-operators devworkspace-webhook-server +``` + +## Test 1: Default Behavior (No Adherence Policy) + +By default, `tlsAdherence` is not set and DWO uses Go's default TLS config. + +```bash +# Check current policy (should be empty) +oc get apiserver cluster -o jsonpath='{.spec.tlsAdherence}{"\n"}' + +# Check controller logs +CONTROLLER_POD=$(oc get pods -n openshift-operators -l app.kubernetes.io/name=devworkspace-controller -o jsonpath='{.items[0].metadata.name}') +oc logs -n openshift-operators $CONTROLLER_POD -c devworkspace-controller | grep -i "tls" +``` + +**Expected**: Log shows `"using Go default TLS configuration"` with empty or no policy. + +## Test 2: Enable StrictAllComponents + +Enable strict adherence and verify DWO applies the cluster TLS profile. + +```bash +# Set StrictAllComponents with a TLS profile +oc patch apiserver cluster --type=merge -p '{"spec":{"tlsSecurityProfile":{"type":"Intermediate","intermediate":{}},"tlsAdherence":"StrictAllComponents"}}' + +# Delete controller pod to pick up new policy +oc delete pod -n openshift-operators -l app.kubernetes.io/name=devworkspace-controller + +# Wait for new pod +sleep 10 + +# Check logs +CONTROLLER_POD=$(oc get pods -n openshift-operators -l app.kubernetes.io/name=devworkspace-controller -o jsonpath='{.items[0].metadata.name}') +oc logs -n openshift-operators $CONTROLLER_POD -c devworkspace-controller | grep -A3 "Applying cluster TLS profile" +``` + +**Expected**: Log shows: +``` +"Applying cluster TLS profile to metrics and webhook servers" + minTLSVersion="VersionTLS12" + adherencePolicy="StrictAllComponents" +``` + +## Test 3: Profile Change Detection + +Verify controller restarts when TLS profile changes. + +```bash +# Change to a different profile (e.g., Modern) +oc patch apiserver cluster --type=merge -p '{"spec":{"tlsSecurityProfile":{"type":"Modern","modern":{}}}}' + +# Wait for automatic restart +sleep 20 + +# Get new pod and check logs +CONTROLLER_POD=$(oc get pods -n openshift-operators -l app.kubernetes.io/name=devworkspace-controller -o jsonpath='{.items[0].metadata.name}') +oc logs -n openshift-operators $CONTROLLER_POD -c devworkspace-controller | grep -A3 "Applying cluster TLS profile" +``` + +**Expected**: Log shows `minTLSVersion="VersionTLS13"` (Modern profile) and a restart message like `"TLS security profile changed; initiating graceful restart"`. + +## Test 4: Policy Change Detection + +Verify controller restarts when adherence policy changes. + +```bash +# Change policy to LegacyAdheringComponentsOnly (does not honor profile) +oc patch apiserver cluster --type=merge -p '{"spec":{"tlsAdherence":"LegacyAdheringComponentsOnly"}}' + +# Wait for automatic restart +sleep 20 + +# Check logs +CONTROLLER_POD=$(oc get pods -n openshift-operators -l app.kubernetes.io/name=devworkspace-controller -o jsonpath='{.items[0].metadata.name}') +oc logs -n openshift-operators $CONTROLLER_POD -c devworkspace-controller | grep -i "tls" +``` + +**Expected**: Log shows `"using Go default TLS configuration"` with `policy="LegacyAdheringComponentsOnly"` and a restart message like `"TLS adherence policy changed; initiating graceful restart"`. + +## Test 5: Smoke Test + +Verify controller functions correctly with TLS adherence enabled. + +```bash +# Re-enable StrictAllComponents +oc patch apiserver cluster --type=merge -p '{"spec":{"tlsAdherence":"StrictAllComponents"}}' + +# Wait for automatic restart +sleep 20 + +# Create test workspace +cat < 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) +} diff --git a/pkg/tlssetup/server_tls_test.go b/pkg/tlssetup/server_tls_test.go new file mode 100644 index 000000000..b1289097e --- /dev/null +++ b/pkg/tlssetup/server_tls_test.go @@ -0,0 +1,88 @@ +// +// 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: "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) + } +} diff --git a/webhook/main.go b/webhook/main.go index 6dd976f54..f38973c27 100644 --- a/webhook/main.go +++ b/webhook/main.go @@ -20,9 +20,7 @@ import ( "flag" "fmt" "os" - "os/signal" "runtime" - "syscall" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" @@ -34,6 +32,7 @@ import ( "github.com/devfile/devworkspace-operator/pkg/cache" "github.com/devfile/devworkspace-operator/pkg/config" "github.com/devfile/devworkspace-operator/pkg/infrastructure" + "github.com/devfile/devworkspace-operator/pkg/tlssetup" "github.com/devfile/devworkspace-operator/version" "github.com/devfile/devworkspace-operator/webhook/server" "github.com/devfile/devworkspace-operator/webhook/workspace" @@ -89,6 +88,13 @@ func main() { os.Exit(1) } + serverTLS, err := tlssetup.BuildServerTLSOptions( + context.Background(), cfg, scheme, log) + if err != nil { + log.Error(err, "failed to build TLS options for servers") + os.Exit(1) + } + namespace, err := infrastructure.GetWatchNamespace() if err != nil { log.Error(err, "Failed to get watch namespace") @@ -105,6 +111,7 @@ func main() { CertDir: server.WebhookServerCertDir, Port: server.WebhookServerPort, Host: server.WebhookServerHost, + TLSOpts: serverTLS.TLSOpts, }) // Create a new Cmd to provide shared dependencies and start components @@ -114,6 +121,7 @@ func main() { BindAddress: metricsAddr, FilterProvider: filters.WithAuthenticationAndAuthorization, SecureServing: true, + TLSOpts: serverTLS.TLSOpts, }, WebhookServer: webhookServer, HealthProbeBindAddress: ":6789", @@ -130,8 +138,15 @@ func main() { os.Exit(1) } - var shutdownChan = make(chan os.Signal, 1) - signal.Notify(shutdownChan, syscall.SIGTERM) + // On OpenShift, watch cluster TLS profile and restart if it changes. + signalCtx := signals.SetupSignalHandler() + ctx, cancelCtx := context.WithCancel(signalCtx) + defer cancelCtx() + + if err := tlssetup.RegisterSecurityProfileWatcher(mgr, serverTLS, cancelCtx, log); err != nil { + log.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 { @@ -146,7 +161,7 @@ func main() { } log.Info("Starting manager") - if err := mgr.Start(signals.SetupSignalHandler()); err != nil { + if err := mgr.Start(ctx); err != nil { log.Error(err, "Manager exited non-zero") os.Exit(1) }