@@ -2,6 +2,10 @@ package node
22
33import (
44 "context"
5+ "fmt"
6+ "regexp"
7+ "strconv"
8+ "strings"
59 "time"
610
711 g "github.com/onsi/ginkgo/v2"
@@ -87,6 +91,49 @@ var _ = g.Describe("[sig-node] [FeatureGate:ImageVolume] ImageVolume", func() {
8791 verifyVolumeMounted (f , pod2 , "ls" , "/mnt/image/bin/oc" )
8892 })
8993
94+ g .It ("should report kubelet image volume metrics correctly [OCP-84149]" , func (ctx context.Context ) {
95+ const (
96+ podName = "image-volume-metrics-test"
97+ imageRef = "image-registry.openshift-image-registry.svc:5000/openshift/cli:latest"
98+ mountPath = "/mnt/image"
99+ )
100+
101+ // Step 1: Create a pod with OCI image as volume source
102+ g .By ("Creating a pod with OCI image as volume source" )
103+ pod := buildPodWithImageVolume (f .Namespace .Name , "" , podName , imageRef )
104+ pod = createPodAndWaitForRunning (ctx , oc , pod )
105+
106+ // Step 2: Verify the image is mounted successfully and read-only
107+ g .By ("Verifying image volume is mounted into the container" )
108+ verifyImageVolumeMounted (f , pod , mountPath )
109+
110+ g .By ("Verifying the mounted volume is read-only" )
111+ verifyVolumeReadOnly (f , pod , mountPath )
112+
113+ // Step 3: Check kubelet metrics about image volume
114+ g .By ("Checking kubelet metrics for image volume" )
115+ metrics , err := getKubeletMetrics (ctx , oc , pod .Spec .NodeName )
116+ o .Expect (err ).NotTo (o .HaveOccurred (), "Failed to get kubelet metrics" )
117+
118+ g .By ("Verifying kubelet_image_volume_requested_total metric" )
119+ requestedTotal , found := parseMetricValue (metrics , "kubelet_image_volume_requested_total" )
120+ o .Expect (found ).To (o .BeTrue (), "kubelet_image_volume_requested_total metric should exist" )
121+ o .Expect (requestedTotal ).To (o .BeNumerically (">=" , 1 ),
122+ "kubelet_image_volume_requested_total should be at least 1" )
123+
124+ g .By ("Verifying kubelet_image_volume_mounted_succeed_total metric" )
125+ succeededTotal , found := parseMetricValue (metrics , "kubelet_image_volume_mounted_succeed_total" )
126+ o .Expect (found ).To (o .BeTrue (), "kubelet_image_volume_mounted_succeed_total metric should exist" )
127+ o .Expect (succeededTotal ).To (o .BeNumerically (">=" , 1 ),
128+ "kubelet_image_volume_mounted_succeed_total should be at least 1" )
129+
130+ g .By ("Verifying kubelet_image_volume_mounted_errors_total metric" )
131+ errorsTotal , found := parseMetricValue (metrics , "kubelet_image_volume_mounted_errors_total" )
132+ o .Expect (found ).To (o .BeTrue (), "kubelet_image_volume_mounted_errors_total metric should exist" )
133+ o .Expect (errorsTotal ).To (o .Equal (0 ),
134+ "kubelet_image_volume_mounted_errors_total should be 0" )
135+ })
136+
90137 g .Context ("when subPath is used" , func () {
91138 g .It ("should handle image volume with subPath" , func (ctx context.Context ) {
92139 pod := buildPodWithImageVolumeSubPath (f .Namespace .Name , "" , podName , image , "bin" )
@@ -186,3 +233,82 @@ func buildPodWithMultipleImageVolumes(namespace, nodeName, podName, image1, imag
186233 })
187234 return pod
188235}
236+
237+ // verifyImageVolumeMounted verifies that the image volume is mounted and accessible
238+ func verifyImageVolumeMounted (f * framework.Framework , pod * v1.Pod , mountPath string ) {
239+ g .By (fmt .Sprintf ("Checking if volume is mounted at %s" , mountPath ))
240+
241+ // Verify the content of the expected file
242+ stdout := e2epod .ExecCommandInContainer (f , pod .Name , pod .Spec .Containers [0 ].Name ,
243+ "cat" , mountPath + "/etc/system-release" )
244+ o .Expect (stdout ).To (o .ContainSubstring ("Red Hat Enterprise Linux release" ), "File content should include 'Red Hat Enterprise Linux release'" )
245+ }
246+
247+ // verifyVolumeReadOnly verifies that the mounted volume is read-only
248+ func verifyVolumeReadOnly (f * framework.Framework , pod * v1.Pod , mountPath string ) {
249+ g .By ("Verifying the volume is mounted as read-only" )
250+
251+ // Check mount options
252+ stdout := e2epod .ExecCommandInContainer (f , pod .Name , pod .Spec .Containers [0 ].Name ,
253+ "mount" )
254+ o .Expect (stdout ).To (o .ContainSubstring (mountPath ), "Mount point should be listed" )
255+
256+ // Verify read-only in mount output
257+ mountLines := strings .Split (stdout , "\n " )
258+ for _ , line := range mountLines {
259+ if strings .Contains (line , mountPath ) {
260+ o .Expect (line ).To (o .MatchRegexp (`\bro\b` ),
261+ "Volume should be mounted with 'ro' (read-only) option" )
262+ framework .Logf ("Mount info: %s" , line )
263+ break
264+ }
265+ }
266+
267+ // Try to write to the volume (should fail)
268+ g .By ("Attempting to write to the read-only volume (should fail)" )
269+ _ , _ , err := e2epod .ExecCommandInContainerWithFullOutput (f , pod .Name , pod .Spec .Containers [0 ].Name ,
270+ "touch" , mountPath + "/testfile" )
271+ o .Expect (err ).To (o .HaveOccurred (), "Writing to read-only volume should fail" )
272+ }
273+
274+ // getKubeletMetrics fetches kubelet metrics from a specific node
275+ func getKubeletMetrics (ctx context.Context , oc * exutil.CLI , nodeName string ) (string , error ) {
276+ metricsPath := fmt .Sprintf ("/api/v1/nodes/%s/proxy/metrics" , nodeName )
277+
278+ data , err := oc .AdminKubeClient ().CoreV1 ().RESTClient ().Get ().
279+ AbsPath (metricsPath ).
280+ DoRaw (ctx )
281+ if err != nil {
282+ return "" , fmt .Errorf ("failed to get metrics from node %s: %w" , nodeName , err )
283+ }
284+
285+ return string (data ), nil
286+ }
287+
288+ // parseMetricValue parses a Prometheus metric value from metrics output
289+ // it returns (value, true) when it finds expected metricName, and value is x in example 'kubelet_image_volume_requested_total x'
290+ // it returns (0, false) when it can't find expected metricName
291+ func parseMetricValue (metrics , metricName string ) (int , bool ) {
292+ // Look for lines like: kubelet_image_volume_requested_total 1
293+ // Skip HELP and TYPE lines
294+ re := regexp .MustCompile (fmt .Sprintf (`^%s\s+(\d+)` , metricName ))
295+
296+ lines := strings .Split (metrics , "\n " )
297+ for _ , line := range lines {
298+ line = strings .TrimSpace (line )
299+ if strings .HasPrefix (line , "#" ) {
300+ continue // Skip comment lines
301+ }
302+
303+ matches := re .FindStringSubmatch (line )
304+ if len (matches ) == 2 {
305+ value , err := strconv .Atoi (matches [1 ])
306+ if err == nil {
307+ return value , true
308+ }
309+ }
310+ }
311+
312+ framework .Logf ("Metric %s not found in output" , metricName )
313+ return 0 , false
314+ }
0 commit comments