diff --git a/test/e2e/framework/pod/create.go b/test/e2e/framework/pod/create.go index 47c25b2c03f..d134fdced4b 100644 --- a/test/e2e/framework/pod/create.go +++ b/test/e2e/framework/pod/create.go @@ -51,6 +51,7 @@ type Config struct { NodeSelection NodeSelection ImageID imageutils.ImageID PodFSGroupChangePolicy *v1.PodFSGroupChangePolicy + PodSELinuxChangePolicy *v1.PodSELinuxChangePolicy } // CreateUnschedulablePod with given claims based on node selector @@ -224,6 +225,9 @@ func MakePodSpec(podConfig *Config) *v1.PodSpec { if podConfig.PodFSGroupChangePolicy != nil { podSpec.SecurityContext.FSGroupChangePolicy = podConfig.PodFSGroupChangePolicy } + if podConfig.PodSELinuxChangePolicy != nil { + podSpec.SecurityContext.SELinuxChangePolicy = podConfig.PodSELinuxChangePolicy + } setVolumes(podSpec, podConfig.PVCs, podConfig.InlineVolumeSources, podConfig.PVCsReadOnly) SetNodeSelection(podSpec, podConfig.NodeSelection) diff --git a/test/e2e/storage/drivers/csi.go b/test/e2e/storage/drivers/csi.go index 6ef88f955df..aba77e5da79 100644 --- a/test/e2e/storage/drivers/csi.go +++ b/test/e2e/storage/drivers/csi.go @@ -160,6 +160,9 @@ func InitHostPathCSIDriver() storageframework.TestDriver { storageframework.CapMultiplePVsSameID: true, storageframework.CapFSResizeFromSourceNotSupported: true, storageframework.CapVolumeGroupSnapshot: true, + // There are extensive tests that NodeStage / NodePublish are called with -o context in csimock/csi_selinux_mount.go, + // but the csi-driver-hostpath can't physically make -o context to appear in the mount table that the CapSELinuxMount tests expect. + storageframework.CapSELinuxMount: false, // This is needed for the // testsuites/volumelimits.go `should support volume limits` diff --git a/test/e2e/storage/framework/testdriver.go b/test/e2e/storage/framework/testdriver.go index 40b1ecda98c..7c9996f0fc8 100644 --- a/test/e2e/storage/framework/testdriver.go +++ b/test/e2e/storage/framework/testdriver.go @@ -224,6 +224,9 @@ const ( // The driver supports ReadOnlyMany (ROX) access mode CapReadOnlyMany Capability = "capReadOnlyMany" + + // The driver supports SELinuxMount feature + CapSELinuxMount Capability = "seLinuxMount" ) // DriverInfo represents static information about a TestDriver. diff --git a/test/e2e/storage/testsuites/base.go b/test/e2e/storage/testsuites/base.go index 04b4ac163d0..5dd2e8208c3 100644 --- a/test/e2e/storage/testsuites/base.go +++ b/test/e2e/storage/testsuites/base.go @@ -87,6 +87,7 @@ var CSISuites = append(BaseSuites, InitReadWriteOncePodTestSuite, InitVolumeModifyTestSuite, InitVolumeModifyStressTestSuite, + InitSELinuxMountTestSuite, ) func getVolumeOpsFromMetricsForPlugin(ms testutil.Metrics, pluginName string) opCounts { diff --git a/test/e2e/storage/testsuites/selinuxmount.go b/test/e2e/storage/testsuites/selinuxmount.go new file mode 100644 index 00000000000..b99d1f234da --- /dev/null +++ b/test/e2e/storage/testsuites/selinuxmount.go @@ -0,0 +1,232 @@ +/* +Copyright 2025 The Kubernetes 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 testsuites + +import ( + "context" + "fmt" + "strings" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/component-base/featuregate" + "k8s.io/kubernetes/pkg/features" + "k8s.io/kubernetes/test/e2e/feature" + "k8s.io/kubernetes/test/e2e/framework" + e2epod "k8s.io/kubernetes/test/e2e/framework/pod" + e2eskipper "k8s.io/kubernetes/test/e2e/framework/skipper" + e2evolume "k8s.io/kubernetes/test/e2e/framework/volume" + storageframework "k8s.io/kubernetes/test/e2e/storage/framework" + admissionapi "k8s.io/pod-security-admission/api" + "k8s.io/utils/ptr" +) + +type seLinuxMountTestSuite struct { + tsInfo storageframework.TestSuiteInfo +} + +var ( + // Pod SELinux label used in the test pods. + seLinuxLabel = &v1.SELinuxOptions{Level: "s0:c0,c1"} + // The expected SELinux mount option, matching both Debian (system_u:object_r:svirt_lxc_net_t) and Fedore based distros (system_u:object_r:container_file_t) + seLinuxMountOptionRE = "context=\"system_u:object_r:[^:]*:s0:c0,c1\"" +) + +// InitCustomSELinuxMountTestSuite returns seLinuxMountTestSuite that implements TestSuite interface +// using custom test patterns +func InitCustomSELinuxMountTestSuite(patterns []storageframework.TestPattern) storageframework.TestSuite { + return &seLinuxMountTestSuite{ + tsInfo: storageframework.TestSuiteInfo{ + Name: "seLinuxMount", + TestPatterns: patterns, + SupportedSizeRange: e2evolume.SizeRange{ + Min: "1Mi", + }, + TestTags: []interface{}{ + feature.SELinux, + framework.WithFeatureGate(features.SELinuxMountReadWriteOncePod), + framework.WithFeatureGate(features.SELinuxChangePolicy), + }, + }, + } +} + +// InitSELinuxMountTestSuite returns seLinuxMountTestSuite that implements TestSuite interface +// using testsuite default patterns +func InitSELinuxMountTestSuite() storageframework.TestSuite { + patterns := []storageframework.TestPattern{ + storageframework.DefaultFsDynamicPV, + } + return InitCustomSELinuxMountTestSuite(patterns) +} + +func (s *seLinuxMountTestSuite) GetTestSuiteInfo() storageframework.TestSuiteInfo { + return s.tsInfo +} + +func (s *seLinuxMountTestSuite) SkipUnsupportedTests(driver storageframework.TestDriver, pattern storageframework.TestPattern) { + if !driver.GetDriverInfo().Capabilities[storageframework.CapSELinuxMount] { + e2eskipper.Skipf("Driver %q does not support SELinuxMount - skipping", driver.GetDriverInfo().Name) + } +} + +func (s *seLinuxMountTestSuite) DefineTests(driver storageframework.TestDriver, pattern storageframework.TestPattern) { + type local struct { + config *storageframework.PerTestConfig + + resource *storageframework.VolumeResource + pod *v1.Pod + } + var l local + + // Beware that it also registers an AfterEach which renders f unusable. Any code using + // f must run inside an It or Context callback. + f := framework.NewFrameworkWithCustomTimeouts("selinux-mount", storageframework.GetDriverTimeouts(driver)) + f.NamespacePodSecurityLevel = admissionapi.LevelPrivileged + + init := func(ctx context.Context, accessMode v1.PersistentVolumeAccessMode) { + l = local{} + l.config = driver.PrepareTest(ctx, f) + testVolumeSizeRange := s.GetTestSuiteInfo().SupportedSizeRange + l.resource = storageframework.CreateVolumeResourceWithAccessModes(ctx, driver, l.config, pattern, testVolumeSizeRange, []v1.PersistentVolumeAccessMode{accessMode}, nil) + } + + cleanup := func(ctx context.Context) { + var errs []error + if l.pod != nil { + ginkgo.By("Deleting pod") + err := e2epod.DeletePodWithWait(ctx, f.ClientSet, l.pod) + errs = append(errs, err) + l.pod = nil + } + + if l.resource != nil { + errs = append(errs, l.resource.CleanupResource(ctx)) + l.resource = nil + } + + framework.ExpectNoError(errors.NewAggregate(errs), "while cleaning up resource") + } + + tests := []struct { + name string + accessMode v1.PersistentVolumeAccessMode + seLinuxChangePolicy *v1.PodSELinuxChangePolicy + featureGates []featuregate.Feature + expectSELinuxMountOption bool + }{ + { + name: "Pod with RWOP volume and the default policy", + accessMode: v1.ReadWriteOncePod, + seLinuxChangePolicy: nil, // implies mount option + expectSELinuxMountOption: true, + }, + { + name: "Pod with RWO volume and mount policy", + accessMode: v1.ReadWriteOnce, + seLinuxChangePolicy: ptr.To(v1.SELinuxChangePolicyMountOption), + featureGates: []featuregate.Feature{features.SELinuxMount}, + expectSELinuxMountOption: true, + }, + { + name: "Pod with RWOP volume and recursive policy", + accessMode: v1.ReadWriteOncePod, + seLinuxChangePolicy: ptr.To(v1.SELinuxChangePolicyRecursive), + expectSELinuxMountOption: false, + }, + { + name: "Pod with RWO and recursive policy", + accessMode: v1.ReadWriteOnce, + seLinuxChangePolicy: ptr.To(v1.SELinuxChangePolicyRecursive), + expectSELinuxMountOption: false, + }, + } + for _, test := range tests { + framework.Context(test.name, func() { + // Compose framework.It arguments dynamically, because the feature gates are dynamic + var args []interface{} + // Test name + if test.expectSELinuxMountOption { + args = append(args, "should mount volumes with SELinux mount option") + } else { + args = append(args, "should mount volumes without SELinux mount option") + } + // Feature gates + for _, fg := range test.featureGates { + args = append(args, framework.WithFeatureGate(fg)) + } + // The test body + args = append(args, func(ctx context.Context) { + init(ctx, test.accessMode) + ginkgo.DeferCleanup(cleanup) + + ginkgo.By("Creating a pod with PVC") + podConfig := e2epod.Config{ + NS: f.Namespace.Name, + PVCs: []*v1.PersistentVolumeClaim{l.resource.Pvc}, + SeLinuxLabel: seLinuxLabel, + NodeSelection: l.config.ClientNodeSelection, + PodSELinuxChangePolicy: test.seLinuxChangePolicy, + } + var err error + l.pod, err = e2epod.CreateSecPod(ctx, f.ClientSet, &podConfig, f.Timeouts.PodStart) + framework.ExpectNoError(err, "while creating the pod") + + ginkgo.By("Waiting for pod to be ready") + err = e2epod.WaitForPodRunningInNamespace(ctx, f.ClientSet, l.pod) + framework.ExpectNoError(err, "while waiting for the pod to be ready") + + ginkgo.By("Checking the volume mount options") + mountOptions, err := getVolumeMountOptions(f, l.pod.Name, "write-pod", "/mnt/volume1") + framework.ExpectNoError(err, "while getting the mount options") + framework.Logf("Detected mount options: %s", mountOptions) + if test.expectSELinuxMountOption { + gomega.Expect(mountOptions).To(gomega.MatchRegexp(seLinuxMountOptionRE), "-o context mount option should be present") + } else { + gomega.Expect(mountOptions).NotTo(gomega.MatchRegexp(seLinuxMountOptionRE), "-o context mount option should not be present") + } + }) + framework.It(args...) + }) + } +} + +func getVolumeMountOptions(f *framework.Framework, podName string, containerName string, volumePath string) (string, error) { + cmd := []string{"cat", "/proc/mounts"} + stdout, stderr, err := e2epod.ExecCommandInContainerWithFullOutput(f, podName, containerName, cmd...) + if err != nil { + // Log all details about the call, except the error that will be logged by the caller + framework.Logf("Executed: %s", strings.Join(cmd, " ")) + framework.Logf("stdout: %s", stdout) + framework.Logf("stderr: %s", stderr) + return "", err + } + + for _, line := range strings.Split(stdout, "\n") { + parts := strings.Fields(line) + if len(parts) < 4 { + continue + } + mountPath := parts[1] + if mountPath == volumePath { + return parts[3], nil + } + } + return "", fmt.Errorf("volume path %s not found in /proc/mounts", volumePath) +}