From 26d5228ec39b8d02647d8dda2f6e3706ffafa2f3 Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Wed, 5 Aug 2026 12:21:32 +0200 Subject: [PATCH 1/7] check_drivesize: fix volume discovery when volume is not mounted to a letter. if mount is not mounted to a drive letter and to a folder like "C:\mounttest" its "name" attribute is set. "drive" attribute is empty. fill them according to the folder path organize the attributes nicely in the custom path decivder working on volumes, building up attributes like drive_or_id , drive_or_name , drive_or_name_or_id add _matching_volume_path attribute, this is used when folder= attrbitue is set and it picks a volume in its parent path as its matching volume. folders are added with their attributes 'id' and 'drive' set to their custom paths. matching volume path is lost, unless we save it to '_matching_volume_path' attribute '_matching_volume_path' is then used in the setDeviceInfo call rename the testPath as volumeTestPath for clarity, it is tested against volumes. it starts off from cleanedPath so no need to clean it again. additionally, improve some comments --- pkg/snclient/check_drivesize_windows.go | 50 ++++++++++++++------ pkg/snclient/check_drivesize_windows_test.go | 15 +++++- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/pkg/snclient/check_drivesize_windows.go b/pkg/snclient/check_drivesize_windows.go index 8a4f179e..8fd0b46e 100644 --- a/pkg/snclient/check_drivesize_windows.go +++ b/pkg/snclient/check_drivesize_windows.go @@ -277,7 +277,12 @@ func (l *CheckDrivesize) setDeviceInfo(drive map[string]string) { } // drivePath needs to be in form 'X:\' - drivePath := strings.ToUpper(drive["drive_or_id"]) + drivePath := drive["drive_or_id"] + if matchingVolumePath, ok := drive["_matching_volume_path"]; matchingVolumePath != "" && ok { + drivePath = drive["_matching_volume_path"] + } + drivePath = strings.ToUpper(drivePath) + if !strings.HasSuffix(drivePath, "\\") { drivePath += "\\" } @@ -706,27 +711,24 @@ func (l *CheckDrivesize) setCustomPath(path string, requiredDrives map[string]ma // try to find closest matching volume availVolumes := map[string]map[string]string{} l.setVolumes(availVolumes) + log.Tracef("available volumes: %v", availVolumes) - testPath := strings.TrimSuffix(cleanedPath, "\\") + "\\" - // make first character uppercase because drives are uppercase in the volume list - if len(testPath) > 1 { - testPath = strings.ToUpper(testPath[0:1]) + testPath[1:] - } + volumeTestPath := strings.TrimSuffix(cleanedPath, "\\") + "\\" var match *map[string]string for i := range availVolumes { volume := availVolumes[i] - // parent fallback means parent folders of a drive are valid as well - if parentFallback && volume["drive"] != "" && - strings.HasPrefix(strings.ToUpper(testPath), strings.ToUpper(volume["drive"])) { - if match == nil || len((*match)["drive"]) < len(volume["drive"]) { + // if parentFallback argument is true, consider parent folders of a drive/mount as valid matches for the given custom search path + if parentFallback && volume["name"] != "" && + strings.HasPrefix(strings.ToUpper(volumeTestPath), strings.ToUpper(volume["name"])) { + if match == nil || len((*match)["name"]) < len(volume["name"]) { match = &volume } } - if strings.EqualFold(testPath, volume["drive"]) { + if strings.EqualFold(volumeTestPath, volume["name"]) { match = &volume break @@ -734,17 +736,35 @@ func (l *CheckDrivesize) setCustomPath(path string, requiredDrives map[string]ma } if match != nil { requiredDrives[path] = utils.CloneStringMap(*match) + // "drive" and "name" attribute is set to custom search path + requiredDrives[path]["id"] = path requiredDrives[path]["drive"] = path requiredDrives[path]["name"] = path - requiredDrives[path]["drive_or_name"] = path - requiredDrives[path]["drive_or_name_or_id"] = path + + // save this for the later GetVolumeInformation call + requiredDrives[path]["_matching_volume_path"] = (*match)["drive"] + + requiredDrives[path]["drive_or_name"] = requiredDrives[path]["drive"] + if requiredDrives[path]["drive_or_name"] == "" { + requiredDrives[path]["drive_or_name"] = requiredDrives[path]["name"] + } + + requiredDrives[path]["drive_or_id"] = requiredDrives[path]["drive"] + if requiredDrives[path]["drive_or_id"] == "" { + requiredDrives[path]["drive_or_id"] = requiredDrives[path]["id"] + } + + requiredDrives[path]["drive_or_name_or_id"] = requiredDrives[path]["drive_or_name"] + if requiredDrives[path]["drive_or_name_or_id"] == "" { + requiredDrives[path]["drive_or_name_or_id"] = requiredDrives[path]["id"] + } return nil } - // add anyway to generate an error later with more default values filled in + // if there is no match, add it anyway with an error, which will be printed as is entry := l.driveEntry(path) - entry["_error"] = fmt.Sprintf("%s not mounted", path) + entry["_error"] = fmt.Sprintf("could not find a drive or volume matching path %q", path) requiredDrives[path] = entry return nil diff --git a/pkg/snclient/check_drivesize_windows_test.go b/pkg/snclient/check_drivesize_windows_test.go index 95becdc5..879d52c9 100644 --- a/pkg/snclient/check_drivesize_windows_test.go +++ b/pkg/snclient/check_drivesize_windows_test.go @@ -102,13 +102,24 @@ func TestCheckDrivesize(t *testing.T) { // must not match res = snc.RunCheck("check_drivesize", []string{"warn=used>100%", "crit=used>100%", "drive=c:\\Windows"}) assert.Equalf(t, CheckExitUnknown, res.State, "state UNKNOWN") - assert.Contains(t, string(res.BuildPluginOutput()), `not mounted`, "output matches") + assert.Contains(t, string(res.BuildPluginOutput()), `could not find a drive or volume matching path`, "output matches") - res = snc.RunCheck("check_drivesize", []string{"warn=used>100%", "crit=used>100%", "folder=c:\\Windows"}) + StopTestAgent(t, snc) +} + +func TestCheckDrivesizeFolder(t *testing.T) { + snc := StartTestAgent(t, "") + + res := snc.RunCheck("check_drivesize", []string{"warn=used>100%", "crit=used>100%", "folder=c:\\Windows"}) assert.Equalf(t, CheckExitOK, res.State, "state OK") assert.Contains(t, string(res.BuildPluginOutput()), `OK - All 1 drive`, "output matches") assert.Contains(t, string(res.BuildPluginOutput()), `c:\Windows used %`, "output matches") + res = snc.RunCheck("check_drivesize", []string{"warn=used>100%", "crit=used>100%", "folder=C:\\Windows"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + assert.Contains(t, string(res.BuildPluginOutput()), `OK - All 1 drive`, "output matches") + assert.Contains(t, string(res.BuildPluginOutput()), `C:\Windows used %`, "output matches") + // check with forward slash res = snc.RunCheck("check_drivesize", []string{"warn=used>100%", "crit=used>100%", "folder=c:/Windows"}) assert.Equalf(t, CheckExitOK, res.State, "state OK") From 793b39cf28bc12de8d88cbc21bf53027c82dbc82 Mon Sep 17 00:00:00 2001 From: Ahmet Ozturk Date: Thu, 6 Aug 2026 14:00:39 +0200 Subject: [PATCH 2/7] check_drivesize: fix the _matching_volume_path attribute matched volume might have its drive as empty string, if it is a volume mounted to a folder --- pkg/snclient/check_drivesize_windows.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/snclient/check_drivesize_windows.go b/pkg/snclient/check_drivesize_windows.go index 8fd0b46e..6d4f41c4 100644 --- a/pkg/snclient/check_drivesize_windows.go +++ b/pkg/snclient/check_drivesize_windows.go @@ -735,6 +735,8 @@ func (l *CheckDrivesize) setCustomPath(path string, requiredDrives map[string]ma } } if match != nil { + log.Tracef("found volume matching path: %q , volumeTestPath: %q , volume: %v", path, volumeTestPath, (*match)) + requiredDrives[path] = utils.CloneStringMap(*match) // "drive" and "name" attribute is set to custom search path requiredDrives[path]["id"] = path @@ -742,7 +744,7 @@ func (l *CheckDrivesize) setCustomPath(path string, requiredDrives map[string]ma requiredDrives[path]["name"] = path // save this for the later GetVolumeInformation call - requiredDrives[path]["_matching_volume_path"] = (*match)["drive"] + requiredDrives[path]["_matching_volume_path"] = (*match)["name"] requiredDrives[path]["drive_or_name"] = requiredDrives[path]["drive"] if requiredDrives[path]["drive_or_name"] == "" { From 1dcec6dc3d67ecbfe04d4fe5e5336d5ae57c2c63 Mon Sep 17 00:00:00 2001 From: Ahmet Ozturk Date: Fri, 7 Aug 2026 12:03:17 +0200 Subject: [PATCH 3/7] check_drivesize: ai assisted add virtual disk checks a virtual disk is preapred in the temporary test folder, with and mounted to another folder in the temporary test folder. a partition table is created and a ntfs filesystem is set up with a partition spanning all available size, and then its mounted to the temporary path the tests can then create files and folders through the mounted path and perform end-to-end alike tests regarding check_drivesize. the idea is to prevent further regressions in every change added tests: - CheckDrivesize.setCustomPath -> specify the mount root path , or a folder inside the root path , both should correctly memorize the volume mount path to be used in GetVolumeInformation call - check_drivesize drive=[mount path] -> should use mount path as the perfdata prefix - total should be free+used - default empty volume does not hit the crit='used gt 90' threshold , but hits it once its been filled up to 95 --- .../check_drivesize_windows_vhdx_test.go | 331 ++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 pkg/snclient/check_drivesize_windows_vhdx_test.go diff --git a/pkg/snclient/check_drivesize_windows_vhdx_test.go b/pkg/snclient/check_drivesize_windows_vhdx_test.go new file mode 100644 index 00000000..07b49b69 --- /dev/null +++ b/pkg/snclient/check_drivesize_windows_vhdx_test.go @@ -0,0 +1,331 @@ +//go:build windows + +package snclient + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "github.com/shirou/gopsutil/v4/disk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +const ( + cmdTimeout = 2 * time.Minute + drivesizeVhdxSizeMiB = 10 + maxVhdxSizeBytes = 50 * 1024 * 1024 // used in check_drivesize assesments +) + +func hasElevatedPrivileges() bool { + var token windows.Token + if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil { + return false + } + defer token.Close() + + return token.IsElevated() +} + +func runDiskpart(t *testing.T, script string) { + t.Helper() + + scriptDir := t.TempDir() + scriptPath := filepath.Join(scriptDir, "diskpart.txt") + require.NoErrorf(t, os.WriteFile(scriptPath, []byte(script), 0o600), "writing diskpart script") + + ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "diskpart", "/s", scriptPath).CombinedOutput() + if err != nil { + require.NoErrorf(t, err, "diskpart failed: %s\n%s", err.Error(), string(out)) + } +} + +// setupDirectoryMountedVolume creates a vhdx volume and mounts it at a directory inside a temp folder. +// the volume is detached again when the test finishes. +// everything is in golang temp test dir, which is deleted once the test ends +func setupDirectoryMountedVolume(t *testing.T, sizeMiB int) string { + t.Helper() + + tempDir := t.TempDir() + vhdDir := filepath.Join(tempDir, "vhds") + mountPath := filepath.Join(tempDir, "testmount", "disk3") + require.NoErrorf(t, os.MkdirAll(vhdDir, 0o700), "creating VHD directory") + require.NoErrorf(t, os.MkdirAll(mountPath, 0o700), "creating volume mount directory") + + vhdPath := filepath.Join(vhdDir, "snclient-drivesize-test.vhdx") + + t.Logf("vhdx test: temp test dir: %s", tempDir) + t.Logf("vhdx test: vhd directory: %s", vhdDir) + t.Logf("vhdx test: vhd file: %s", vhdPath) + t.Logf("vhdx test: mount path: %s", mountPath) + + t.Cleanup(func() { + //nolint:gocritic // %q would double-escape the backslashes in the windows path + runDiskpart(t, fmt.Sprintf("select vdisk file=\"%s\"\ndetach vdisk\n", vhdPath)) + _ = os.RemoveAll(vhdDir) + _, err := os.Stat(vhdDir) + vhdDirExists := err == nil + t.Logf("vhdx test: detached volume, %s still present: %v", vhdDir, vhdDirExists) + }) + + createScript := fmt.Sprintf(`create vdisk file="%s" maximum=%d type=expandable +select vdisk file="%s" +attach vdisk +convert gpt +create partition primary +format fs=ntfs quick label="snclient-test" +assign mount="%s" +`, vhdPath, sizeMiB, vhdPath, mountPath) + runDiskpart(t, createScript) + + return mountPath +} + +// fillVolumeToPercent writes a file into the volume until the used space reaches the target percentage. +// It returns the achieved used percentage, which may be lower if the volume ran full first. +func fillVolumeToPercent(t *testing.T, mountPath string, targetPercent float64) float64 { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout) + defer cancel() + + fillPath := filepath.Join(mountPath, "fill.dat") + t.Logf("vhdx test: fill file: %s", fillPath) + file, err := os.OpenFile(fillPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + require.NoErrorf(t, err, "opening fill file") + defer file.Close() + + // fill with 256 kb chunks + fillChunkSize := 256 * 1024 + + chunk := make([]byte, fillChunkSize) + usage, err := disk.UsageWithContext(ctx, mountPath) + require.NoErrorf(t, err, "reading usage of %s", mountPath) + for usage.UsedPercent < targetPercent { + if _, writeErr := file.Write(chunk); writeErr != nil { + break + } + usage, err = disk.UsageWithContext(ctx, mountPath) + require.NoErrorf(t, err, "reading usage of %s", mountPath) + } + + return usage.UsedPercent +} + +// parseSizeUsage reads the size, used and free bytes from a check run that printed +// %(drive_or_name) %(size_bytes) %(used_bytes) %(free_bytes) +// as detail syntax. +func parseSizeUsage(t *testing.T, path, output string) (size, used, free uint64) { + t.Helper() + + re := regexp.MustCompile(regexp.QuoteMeta(path) + ` (\d+) (\d+) (\d+)`) + matches := re.FindStringSubmatch(output) + require.NotNilf(t, matches, "could not find size/used/free for %q in output:\n%s", path, output) + + var err error + if size, err = strconv.ParseUint(matches[1], 10, 64); err != nil { + t.Fatalf("parsing size: %v", err) + } + if used, err = strconv.ParseUint(matches[2], 10, 64); err != nil { + t.Fatalf("parsing used: %v", err) + } + if free, err = strconv.ParseUint(matches[3], 10, 64); err != nil { + t.Fatalf("parsing free: %v", err) + } + + return size, used, free +} + +func normalizeVolumePath(path string) string { + return strings.ToUpper(strings.TrimSuffix(path, "\\")) +} + +func TestCheckDrivesizeVolumeMountCustomPathMatching(t *testing.T) { + if !hasElevatedPrivileges() { + t.Skipf("creating a vhdx volume requires elevated privileges") + } + + mountPath := setupDirectoryMountedVolume(t, drivesizeVhdxSizeMiB) + + dummyFolder := filepath.Join(mountPath, "dummy1", "dummy2", "dummy3") + require.NoErrorf(t, os.MkdirAll(dummyFolder, 0o700), "creating dummy folder inside the mounted volume") + t.Logf("vhdx test: dummy folder: %s", dummyFolder) + + checkDrivesize := &CheckDrivesize{} + // the searchPath can directly be the volume mount point or another path inside the volume mount point + // in both cases, the code should retain the volume mount point, inside _matching_volume_path , and use it when calling GetVolumeInformation + tests := []struct { + name string + searchPath string + fallback bool + wantDrive string + }{ + {"volume root", mountPath, false, mountPath}, + {"folder inside volume", dummyFolder, true, dummyFolder}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + requiredDrives := map[string]map[string]string{} + err := checkDrivesize.setCustomPath(testCase.searchPath, requiredDrives, testCase.fallback) + require.NoErrorf(t, err, "setCustomPath(%q) works", testCase.searchPath) + + entry := requiredDrives[testCase.searchPath] + require.NotNilf(t, entry, "entry exists for %s", testCase.searchPath) + + assert.Equalf(t, testCase.wantDrive, entry["drive"], "drive uses the search path") + assert.Equalf(t, testCase.wantDrive, entry["drive_or_name"], "drive_or_name uses the search path") + assert.Equalf(t, testCase.wantDrive, entry["drive_or_id"], "drive_or_id uses the search path") + assert.Equalf(t, normalizeVolumePath(mountPath), normalizeVolumePath(entry["_matching_volume_path"]), + "_matching_volume_path retains the volume mount path") + }) + } +} + +func TestCheckDrivesizeVolumeMount(t *testing.T) { + if !hasElevatedPrivileges() { + t.Skipf("creating a vhdx volume requires elevated privileges") + } + + snc := StartTestAgent(t, "") + defer StopTestAgent(t, snc) + + mountPath := setupDirectoryMountedVolume(t, drivesizeVhdxSizeMiB) + + dummyFolder := filepath.Join(mountPath, "dummy1", "dummy2", "dummy3") + require.NoErrorf(t, os.MkdirAll(dummyFolder, 0o700), "creating dummy folder inside the mounted volume") + t.Logf("vhdx test: dummy folder: %s", dummyFolder) + + expectedUsage, err := disk.UsageWithContext(context.Background(), mountPath) + require.NoErrorf(t, err, "reading expected usage of mounted volume") + assert.Lessf(t, expectedUsage.Total, uint64(maxVhdxSizeBytes), "mounted volume is the small vhdx, not a real drive") + + // drive argument pointing at the volume mount path + res := snc.RunCheck("check_drivesize", []string{ + "drive=" + mountPath, + "warn=used>100%", + "crit=used>100%", + "show-all", + }) + require.Equalf(t, CheckExitOK, res.State, "state OK") + output := string(res.BuildPluginOutput()) + assert.Containsf(t, output, mountPath, "output contains mount path") + assert.Containsf(t, output, fmt.Sprintf("'%s used'=", mountPath), "perfdata used label") + assert.Containsf(t, output, fmt.Sprintf("'%s used %%'=", mountPath), "perfdata used percent label") + + res = snc.RunCheck("check_drivesize", []string{ + "drive=" + mountPath, + "warn=used>100%", + "crit=used>100%", + "show-all", + "detail-syntax=%(drive_or_name) %(size_bytes) %(used_bytes) %(free_bytes)", + }) + output = string(res.BuildPluginOutput()) + size, used, free := parseSizeUsage(t, mountPath, output) + assert.Equalf(t, expectedUsage.Total, size, "reported size matches the mounted volume") + assert.Equalf(t, used+free, size, "free + used == size") + + // folder argument pointing at a folder inside the mounted volume + // output and perfdata prefix should use folder path + res = snc.RunCheck("check_drivesize", []string{ + "folder=" + dummyFolder, + "warn=used>100%", + "crit=used>100%", + "show-all", + }) + require.Equalf(t, CheckExitOK, res.State, "folder inside mounted volume resolves to the volume") + output = string(res.BuildPluginOutput()) + assert.Containsf(t, output, dummyFolder, "output contains the folder path") + assert.Containsf(t, output, fmt.Sprintf("'%s used'=", dummyFolder), "perfdata uses the folder path as label") + + res = snc.RunCheck("check_drivesize", []string{ + "folder=" + dummyFolder, + "warn=used>100%", + "crit=used>100%", + "show-all", + "detail-syntax=%(drive_or_name) %(size_bytes) %(used_bytes) %(free_bytes)", + }) + folderSize, _, _ := parseSizeUsage(t, dummyFolder, string(res.BuildPluginOutput())) + assert.Equalf(t, expectedUsage.Total, folderSize, "folder check reports the volume capacity") + + // all discovery includes the directory mounted volume + res = snc.RunCheck("check_drivesize", []string{"warn=used>100%", "crit=used>100%", "show-all"}) + require.Equalf(t, CheckExitOK, res.State, "state OK") + assert.Containsf(t, string(res.BuildPluginOutput()), mountPath, "volume is discovered with all") + + // perf-config and perf-syntax like the end-to-end test + res = snc.RunCheck("check_drivesize", []string{ + "drive=" + mountPath, + "warn=used>100%", + "crit=used>100%", + "perf-config=*(unit:Gb)", + "perf-syntax=%(key:lc)", + "show-all", + }) + require.Equalf(t, CheckExitOK, res.State, "state OK") + assert.Containsf(t, string(res.BuildPluginOutput()), "disk3", "perfdata labels contain the volume name") +} + +func TestCheckDrivesizeVolumeMountFull(t *testing.T) { + if !hasElevatedPrivileges() { + t.Skipf("creating a vhdx volume requires elevated privileges") + } + + snc := StartTestAgent(t, "") + defer StopTestAgent(t, snc) + + mountPath := setupDirectoryMountedVolume(t, drivesizeVhdxSizeMiB) + + // sparse volume is far below the critical threshold at the start + res := snc.RunCheck("check_drivesize", []string{ + "drive=" + mountPath, + "warning=none", + "crit='used gt 90'", + "show-all", + }) + require.Equalf(t, CheckExitOK, res.State, "sparse volume is OK") + + // fill the volume up to ~95% + fillTargetPercent := 95.0 + fillThresholdPercent := 90.0 + + achieved := fillVolumeToPercent(t, mountPath, fillTargetPercent) + require.Greaterf(t, achieved, fillThresholdPercent, "volume was filled beyond the threshold") + + // full volume must trigger the critical threshold + res = snc.RunCheck("check_drivesize", []string{ + "drive=" + mountPath, + "warning=none", + "crit='used gt 90'", + "show-all", + }) + require.Equalf(t, CheckExitCritical, res.State, "full volume triggers critical") + output := string(res.BuildPluginOutput()) + assert.Containsf(t, output, mountPath, "output contains mount path") + + re := regexp.MustCompile(regexp.QuoteMeta(fmt.Sprintf("'%s used %%'=", mountPath)) + `([\d.]+)%`) + matches := re.FindStringSubmatch(output) + require.NotNilf(t, matches, "perfdata used percent parsed from output:\n%s", output) + usedPct, err := strconv.ParseFloat(matches[1], 64) + require.NoErrorf(t, err, "parsing used percent") + assert.Greaterf(t, usedPct, fillThresholdPercent, "perfdata used percent exceeds the threshold") + + // warning threshold triggers as well , but not critical which requires 99 percent + res = snc.RunCheck("check_drivesize", []string{ + "drive=" + mountPath, + "warning='used gt 90'", + "crit='used gt 99'", + "show-all", + }) + require.Equalf(t, CheckExitWarning, res.State, "full volume triggers warning") +} From 3052ce1cb4b2f0f9f0498d4b9de00d59e766b306 Mon Sep 17 00:00:00 2001 From: Ahmet Ozturk Date: Fri, 7 Aug 2026 12:26:21 +0200 Subject: [PATCH 4/7] check_drivesize: ai assisted, improve creation, discovery and cleanup steps for the vhdx drive. fixes some race conditions the github runner has problems realizing a new volume is mounted - wait until it is picked up correctly by CheckDrivesize.setCustomPath detachingVhdx has its own function, if its already detached it does nothing instead of failing. and also during cleanup - something else keeps a handle open to the vhdx file after its unmounted - wait until it goes away. --- .../check_drivesize_windows_vhdx_test.go | 104 +++++++++++++++++- 1 file changed, 98 insertions(+), 6 deletions(-) diff --git a/pkg/snclient/check_drivesize_windows_vhdx_test.go b/pkg/snclient/check_drivesize_windows_vhdx_test.go index 07b49b69..651e2f5f 100644 --- a/pkg/snclient/check_drivesize_windows_vhdx_test.go +++ b/pkg/snclient/check_drivesize_windows_vhdx_test.go @@ -36,7 +36,7 @@ func hasElevatedPrivileges() bool { return token.IsElevated() } -func runDiskpart(t *testing.T, script string) { +func execDiskpart(t *testing.T, script string) (output string, err error) { t.Helper() scriptDir := t.TempDir() @@ -45,10 +45,97 @@ func runDiskpart(t *testing.T, script string) { ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout) defer cancel() - out, err := exec.CommandContext(ctx, "diskpart", "/s", scriptPath).CombinedOutput() + out, cmdErr := exec.CommandContext(ctx, "diskpart", "/s", scriptPath).CombinedOutput() + + return string(out), cmdErr +} + +func runDiskpart(t *testing.T, script string) { + t.Helper() + + out, err := execDiskpart(t, script) + require.NoErrorf(t, err, "diskpart failed: %s\n%s", err, out) +} + +// detachVhdx detaches the vhdx volume, tolerating an already detached state. +func detachVhdx(t *testing.T, vhdPath string) { + t.Helper() + + //nolint:gocritic // %q would double-escape the backslashes in the windows path + out, err := execDiskpart(t, fmt.Sprintf("select vdisk file=\"%s\"\ndetach vdisk\n", vhdPath)) + if err != nil && strings.Contains(out, "already detached") { + t.Logf("vhdx test: volume already detached") + + return + } + require.NoErrorf(t, err, "diskpart detach failed: %s\n%s", err, out) +} + +// waitForFileUnlock waits until the vhdx file can be removed again. +// the storage stack can keep the file handle open for a while after the volume was detached. +func waitForFileUnlock(t *testing.T, vhdPath string) { + t.Helper() + + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + if err := os.Remove(vhdPath); err == nil { + t.Logf("vhdx test: vhd file removed: %s", vhdPath) + + return + } + time.Sleep(500 * time.Millisecond) + } + t.Logf("vhdx test: vhd file still locked after waiting: %s", vhdPath) +} + +// waitForVolumeDiscovery waits until the volume mounted at mountPath is found by the volume +// discovery used by check_drivesize. the storage stack can take a moment to register a freshly attached volume. +func waitForVolumeDiscovery(t *testing.T, mountPath string) { + t.Helper() + + checkDrivesize := &CheckDrivesize{} + deadline := time.Now().Add(60 * time.Second) + for time.Now().Before(deadline) { + requiredDrives := map[string]map[string]string{} + err := checkDrivesize.setCustomPath(mountPath, requiredDrives, false) + if err == nil { + if entry, ok := requiredDrives[mountPath]; ok && entry["_error"] == "" { + t.Logf("vhdx test: volume %s found by discovery", mountPath) + + return + } + } + time.Sleep(1 * time.Second) + } + t.Fatalf("vhdx test: volume %s not found by discovery within 60s", mountPath) +} + +func logVolumeState(t *testing.T, mountPath string) { + t.Helper() + + usage, err := disk.UsageWithContext(context.Background(), mountPath) + if err != nil { + t.Logf("vhdx test: volume usage error: %s", err.Error()) + } else { + t.Logf("vhdx test: volume total=%d free=%d used=%d", usage.Total, usage.Free, usage.Used) + } + + checkDrivesize := &CheckDrivesize{} + requiredDrives := map[string]map[string]string{} + err = checkDrivesize.setCustomPath(mountPath, requiredDrives, false) if err != nil { - require.NoErrorf(t, err, "diskpart failed: %s\n%s", err.Error(), string(out)) + t.Logf("vhdx test: setCustomPath error: %s", err.Error()) + + return + } + entry := requiredDrives[mountPath] + if entry == nil { + t.Logf("vhdx test: mountPath not found in requiredDrives") + + return } + t.Logf("vhdx test: discovered entry: drive=%q drive_or_id=%q _error=%q _matching_volume_path=%q", + entry["drive"], entry["drive_or_id"], entry["_error"], entry["_matching_volume_path"]) } // setupDirectoryMountedVolume creates a vhdx volume and mounts it at a directory inside a temp folder. @@ -71,12 +158,14 @@ func setupDirectoryMountedVolume(t *testing.T, sizeMiB int) string { t.Logf("vhdx test: mount path: %s", mountPath) t.Cleanup(func() { - //nolint:gocritic // %q would double-escape the backslashes in the windows path - runDiskpart(t, fmt.Sprintf("select vdisk file=\"%s\"\ndetach vdisk\n", vhdPath)) + detachVhdx(t, vhdPath) + waitForFileUnlock(t, vhdPath) _ = os.RemoveAll(vhdDir) + _, err := os.Stat(vhdDir) vhdDirExists := err == nil - t.Logf("vhdx test: detached volume, %s still present: %v", vhdDir, vhdDirExists) + + t.Logf("vhdx test: cleaned up, %s still present: %v", vhdDir, vhdDirExists) }) createScript := fmt.Sprintf(`create vdisk file="%s" maximum=%d type=expandable @@ -88,6 +177,7 @@ format fs=ntfs quick label="snclient-test" assign mount="%s" `, vhdPath, sizeMiB, vhdPath, mountPath) runDiskpart(t, createScript) + waitForVolumeDiscovery(t, mountPath) return mountPath } @@ -211,6 +301,7 @@ func TestCheckDrivesizeVolumeMount(t *testing.T) { assert.Lessf(t, expectedUsage.Total, uint64(maxVhdxSizeBytes), "mounted volume is the small vhdx, not a real drive") // drive argument pointing at the volume mount path + logVolumeState(t, mountPath) res := snc.RunCheck("check_drivesize", []string{ "drive=" + mountPath, "warn=used>100%", @@ -287,6 +378,7 @@ func TestCheckDrivesizeVolumeMountFull(t *testing.T) { mountPath := setupDirectoryMountedVolume(t, drivesizeVhdxSizeMiB) // sparse volume is far below the critical threshold at the start + logVolumeState(t, mountPath) res := snc.RunCheck("check_drivesize", []string{ "drive=" + mountPath, "warning=none", From 73b34d350b05d086c44e43b4b98897268239dc70 Mon Sep 17 00:00:00 2001 From: Ahmet Ozturk Date: Fri, 7 Aug 2026 14:01:49 +0200 Subject: [PATCH 5/7] check_drivesize: ai commit, add a function to exclude a directory from windows defender, could resolve the github runner issue also improve vhdx creation script with three tries to clean it up mount the drive before agents start in tests - might help as well --- .../check_drivesize_windows_vhdx_test.go | 120 +++++++++++++----- 1 file changed, 88 insertions(+), 32 deletions(-) diff --git a/pkg/snclient/check_drivesize_windows_vhdx_test.go b/pkg/snclient/check_drivesize_windows_vhdx_test.go index 651e2f5f..89952ec8 100644 --- a/pkg/snclient/check_drivesize_windows_vhdx_test.go +++ b/pkg/snclient/check_drivesize_windows_vhdx_test.go @@ -23,7 +23,7 @@ import ( const ( cmdTimeout = 2 * time.Minute drivesizeVhdxSizeMiB = 10 - maxVhdxSizeBytes = 50 * 1024 * 1024 // used in check_drivesize assesments + maxVhdxSizeBytes = 50 * 1024 * 1024 // used in check_drivesize assessments ) func hasElevatedPrivileges() bool { @@ -50,11 +50,13 @@ func execDiskpart(t *testing.T, script string) (output string, err error) { return string(out), cmdErr } -func runDiskpart(t *testing.T, script string) { +func runDiskpart(t *testing.T, script string) (output string) { t.Helper() out, err := execDiskpart(t, script) require.NoErrorf(t, err, "diskpart failed: %s\n%s", err, out) + + return out } // detachVhdx detaches the vhdx volume, tolerating an already detached state. @@ -71,12 +73,27 @@ func detachVhdx(t *testing.T, vhdPath string) { require.NoErrorf(t, err, "diskpart detach failed: %s\n%s", err, out) } +// addDefenderExclusion adds a Microsoft Defender exclusion for the given directory, so that +// realtime scanning does not keep a handle on the vhdx file. Best effort only. +func addDefenderExclusion(t *testing.T, directory string) { + t.Helper() + + escaped := strings.ReplaceAll(directory, "'", "''") + ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", + fmt.Sprintf("Add-MpPreference -ExclusionPath '%s'", escaped)).CombinedOutput() + if err != nil { + t.Logf("vhdx test: could not add defender exclusion for %s: %s\n%s", directory, err, out) + } +} + // waitForFileUnlock waits until the vhdx file can be removed again. // the storage stack can keep the file handle open for a while after the volume was detached. func waitForFileUnlock(t *testing.T, vhdPath string) { t.Helper() - deadline := time.Now().Add(30 * time.Second) + deadline := time.Now().Add(90 * time.Second) for time.Now().Before(deadline) { if err := os.Remove(vhdPath); err == nil { t.Logf("vhdx test: vhd file removed: %s", vhdPath) @@ -88,26 +105,44 @@ func waitForFileUnlock(t *testing.T, vhdPath string) { t.Logf("vhdx test: vhd file still locked after waiting: %s", vhdPath) } -// waitForVolumeDiscovery waits until the volume mounted at mountPath is found by the volume -// discovery used by check_drivesize. the storage stack can take a moment to register a freshly attached volume. -func waitForVolumeDiscovery(t *testing.T, mountPath string) { +// volumeReady reports whether the volume mounted at mountPath is usable and found by the volume +// discovery used by check_drivesize. the storage stack can take a moment to register a freshly +// attached volume. +func volumeReady(t *testing.T, mountPath string, timeout time.Duration) bool { t.Helper() checkDrivesize := &CheckDrivesize{} - deadline := time.Now().Add(60 * time.Second) + deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { - requiredDrives := map[string]map[string]string{} - err := checkDrivesize.setCustomPath(mountPath, requiredDrives, false) - if err == nil { - if entry, ok := requiredDrives[mountPath]; ok && entry["_error"] == "" { - t.Logf("vhdx test: volume %s found by discovery", mountPath) + // the mount point has to resolve to the small volume and not to the parent drive + usage, err := disk.UsageWithContext(context.Background(), mountPath) + if err == nil && usage.Total < maxVhdxSizeBytes { + requiredDrives := map[string]map[string]string{} + err := checkDrivesize.setCustomPath(mountPath, requiredDrives, false) + if err == nil { + if entry, ok := requiredDrives[mountPath]; ok && entry["_error"] == "" { + t.Logf("vhdx test: volume %s found by discovery", mountPath) - return + return true + } } } time.Sleep(1 * time.Second) } - t.Fatalf("vhdx test: volume %s not found by discovery within 60s", mountPath) + + return false +} + +// logVolumes logs all volumes currently known to the windows volume discovery. +func logVolumes(t *testing.T) { + t.Helper() + + checkDrivesize := &CheckDrivesize{} + availVolumes := map[string]map[string]string{} + checkDrivesize.setVolumes(availVolumes) + for volumeID, volume := range availVolumes { + t.Logf("vhdx test: volume %s: name=%q drive=%q mounted=%q", volumeID, volume["name"], volume["drive"], volume["mounted"]) + } } func logVolumeState(t *testing.T, mountPath string) { @@ -150,13 +185,45 @@ func setupDirectoryMountedVolume(t *testing.T, sizeMiB int) string { require.NoErrorf(t, os.MkdirAll(vhdDir, 0o700), "creating VHD directory") require.NoErrorf(t, os.MkdirAll(mountPath, 0o700), "creating volume mount directory") - vhdPath := filepath.Join(vhdDir, "snclient-drivesize-test.vhdx") - t.Logf("vhdx test: temp test dir: %s", tempDir) t.Logf("vhdx test: vhd directory: %s", vhdDir) - t.Logf("vhdx test: vhd file: %s", vhdPath) t.Logf("vhdx test: mount path: %s", mountPath) + // realtime scanning can hold a handle on the vhdx file, try to keep it away + addDefenderExclusion(t, tempDir) + + discoveryTimeout := 60 * time.Second + mounted := false + var vhdPath string + for attempt := 1; attempt <= 3 && !mounted; attempt++ { + vhdPath = filepath.Join(vhdDir, fmt.Sprintf("snclient-drivesize-test-%d.vhdx", attempt)) + t.Logf("vhdx test: vhd file: %s", vhdPath) + + createScript := fmt.Sprintf(`create vdisk file="%s" maximum=%d type=expandable +select vdisk file="%s" +attach vdisk +convert gpt +create partition primary +format fs=ntfs quick label="snclient-test" +assign mount="%s" +`, vhdPath, sizeMiB, vhdPath, mountPath) + out := runDiskpart(t, createScript) + t.Logf("vhdx test: diskpart create output:\n%s", out) + + if volumeReady(t, mountPath, discoveryTimeout) { + mounted = true + + break + } + t.Logf("vhdx test: volume %s did not come up, cleaning up attempt %d/3", mountPath, attempt) + detachVhdx(t, vhdPath) + waitForFileUnlock(t, vhdPath) + } + if !mounted { + logVolumes(t) + t.Fatalf("vhdx test: volume %s could not be mounted within 3 attempts", mountPath) + } + t.Cleanup(func() { detachVhdx(t, vhdPath) waitForFileUnlock(t, vhdPath) @@ -168,17 +235,6 @@ func setupDirectoryMountedVolume(t *testing.T, sizeMiB int) string { t.Logf("vhdx test: cleaned up, %s still present: %v", vhdDir, vhdDirExists) }) - createScript := fmt.Sprintf(`create vdisk file="%s" maximum=%d type=expandable -select vdisk file="%s" -attach vdisk -convert gpt -create partition primary -format fs=ntfs quick label="snclient-test" -assign mount="%s" -`, vhdPath, sizeMiB, vhdPath, mountPath) - runDiskpart(t, createScript) - waitForVolumeDiscovery(t, mountPath) - return mountPath } @@ -287,11 +343,11 @@ func TestCheckDrivesizeVolumeMount(t *testing.T) { t.Skipf("creating a vhdx volume requires elevated privileges") } + mountPath := setupDirectoryMountedVolume(t, drivesizeVhdxSizeMiB) + snc := StartTestAgent(t, "") defer StopTestAgent(t, snc) - mountPath := setupDirectoryMountedVolume(t, drivesizeVhdxSizeMiB) - dummyFolder := filepath.Join(mountPath, "dummy1", "dummy2", "dummy3") require.NoErrorf(t, os.MkdirAll(dummyFolder, 0o700), "creating dummy folder inside the mounted volume") t.Logf("vhdx test: dummy folder: %s", dummyFolder) @@ -372,11 +428,11 @@ func TestCheckDrivesizeVolumeMountFull(t *testing.T) { t.Skipf("creating a vhdx volume requires elevated privileges") } + mountPath := setupDirectoryMountedVolume(t, drivesizeVhdxSizeMiB) + snc := StartTestAgent(t, "") defer StopTestAgent(t, snc) - mountPath := setupDirectoryMountedVolume(t, drivesizeVhdxSizeMiB) - // sparse volume is far below the critical threshold at the start logVolumeState(t, mountPath) res := snc.RunCheck("check_drivesize", []string{ From 6a90f523e078a3464de96dc2539635f20d37d646 Mon Sep 17 00:00:00 2001 From: Ahmet Ozturk Date: Fri, 7 Aug 2026 14:17:49 +0200 Subject: [PATCH 6/7] check_drivesize: ai commit, seems like a hail mary - expands path short names like C:\Users\RUNNER~1 to their long form before working on the vhdx --- .../check_drivesize_windows_vhdx_test.go | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pkg/snclient/check_drivesize_windows_vhdx_test.go b/pkg/snclient/check_drivesize_windows_vhdx_test.go index 89952ec8..609eeb99 100644 --- a/pkg/snclient/check_drivesize_windows_vhdx_test.go +++ b/pkg/snclient/check_drivesize_windows_vhdx_test.go @@ -36,6 +36,27 @@ func hasElevatedPrivileges() bool { return token.IsElevated() } +// resolveLongPath expands 8.3 short names (ex.: C:\Users\RUNNER~1) to their long form. +// diskpart stores the vhd backing file path and the mount point as given, so later +// select/detach calls need to use the exact same path string. +func resolveLongPath(path string) (string, error) { + pathPtr, err := windows.UTF16PtrFromString(path) + if err != nil { + return "", err + } + size, _ := windows.GetLongPathName(pathPtr, nil, 0) + if size == 0 { + return "", fmt.Errorf("GetLongPathName returned no size for %s", path) + } + buf := make([]uint16, size) + res, _ := windows.GetLongPathName(pathPtr, &buf[0], size) + if res == 0 { + return "", fmt.Errorf("GetLongPathName returned 0 for %s", path) + } + + return windows.UTF16ToString(buf[:res]), nil +} + func execDiskpart(t *testing.T, script string) (output string, err error) { t.Helper() @@ -180,6 +201,13 @@ func setupDirectoryMountedVolume(t *testing.T, sizeMiB int) string { t.Helper() tempDir := t.TempDir() + // expand 8.3 short names (ex.: C:\Users\RUNNER~1) so that diskpart can match the paths again + // when selecting the vdisk for detaching. otherwise the volume stays attached and the file locked. + if resolved, err := resolveLongPath(tempDir); err != nil { + t.Logf("vhdx test: could not resolve long path of %s: %s", tempDir, err) + } else { + tempDir = resolved + } vhdDir := filepath.Join(tempDir, "vhds") mountPath := filepath.Join(tempDir, "testmount", "disk3") require.NoErrorf(t, os.MkdirAll(vhdDir, 0o700), "creating VHD directory") From e9b876afd50ac4456d02da2646e498adbf55683c Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Fri, 7 Aug 2026 14:28:49 +0200 Subject: [PATCH 7/7] check_drivesize: golangci fixes --- pkg/snclient/check_drivesize_windows_vhdx_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/snclient/check_drivesize_windows_vhdx_test.go b/pkg/snclient/check_drivesize_windows_vhdx_test.go index 609eeb99..24a3bed2 100644 --- a/pkg/snclient/check_drivesize_windows_vhdx_test.go +++ b/pkg/snclient/check_drivesize_windows_vhdx_test.go @@ -42,7 +42,7 @@ func hasElevatedPrivileges() bool { func resolveLongPath(path string) (string, error) { pathPtr, err := windows.UTF16PtrFromString(path) if err != nil { - return "", err + return "", fmt.Errorf("GetLongName error when creating UTF16 string pointer from path %w", err) } size, _ := windows.GetLongPathName(pathPtr, nil, 0) if size == 0 { @@ -102,6 +102,7 @@ func addDefenderExclusion(t *testing.T, directory string) { escaped := strings.ReplaceAll(directory, "'", "''") ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout) defer cancel() + //nolint:gosec // G204: the format uses the escpaed string for the path out, err := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", fmt.Sprintf("Add-MpPreference -ExclusionPath '%s'", escaped)).CombinedOutput() if err != nil {