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
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,166 @@ public async Task DiskSpdExecutorDeletesTestFilesByDefault()
}
}

[Test]
public void DiskSpdExecutorWithRawDiskTargetUsesPhysicalDeviceNumberSyntax_SingleProcessModel()
{
// Bare disks use VC's internal \\.\.PHYSICALDISK{N} identifier.
// The executor uses DiskSpd's native #N syntax (e.g. #1, #2) derived from disk.Index.
IEnumerable<Disk> bareDisks = new List<Disk>
{
this.CreateDisk(1, PlatformID.Win32NT, os: false, @"\\.\PHYSICALDISK1"),
this.CreateDisk(2, PlatformID.Win32NT, os: false, @"\\.\PHYSICALDISK2")
};

this.profileParameters[nameof(DiskSpdExecutor.RawDiskTarget)] = true;
this.profileParameters[nameof(DiskSpdExecutor.ProcessModel)] = WorkloadProcessModel.SingleProcess;

List<string> capturedArguments = new List<string>();
this.ProcessManager.OnCreateProcess = (exe, arguments, workingDir) =>
{
capturedArguments.Add(arguments);
return new InMemoryProcess
{
StartInfo = new ProcessStartInfo { FileName = exe, Arguments = arguments }
};
};

using (TestDiskSpdExecutor executor = new TestDiskSpdExecutor(this.Dependencies, this.profileParameters))
{
executor.CreateWorkloadProcesses("diskspd.exe", "-b4K -r4K -t1 -o1 -w100", bareDisks, WorkloadProcessModel.SingleProcess);
}

Assert.AreEqual(1, capturedArguments.Count);
// DiskSpd #N syntax -- derived from disk.Index, not disk.DevicePath.
// DiskSpd uses IOCTL_DISK_GET_DRIVE_GEOMETRY_EX internally; no -c needed.
Assert.IsTrue(capturedArguments[0].Contains(" #1"));
Assert.IsTrue(capturedArguments[0].Contains(" #2"));
// No file path style references should appear.
Assert.IsFalse(capturedArguments[0].Contains("diskspd-test.dat"));
}

[Test]
public void DiskSpdExecutorWithRawDiskTargetUsesPhysicalDeviceNumberSyntax_SingleProcessPerDiskModel()
{
IEnumerable<Disk> bareDisks = new List<Disk>
{
this.CreateDisk(1, PlatformID.Win32NT, os: false, @"\\.\PHYSICALDISK1"),
this.CreateDisk(2, PlatformID.Win32NT, os: false, @"\\.\PHYSICALDISK2")
};

this.profileParameters[nameof(DiskSpdExecutor.RawDiskTarget)] = true;

List<string> capturedArguments = new List<string>();
int processCount = 0;
this.ProcessManager.OnCreateProcess = (exe, arguments, workingDir) =>
{
capturedArguments.Add(arguments);
processCount++;
return new InMemoryProcess
{
StartInfo = new ProcessStartInfo { FileName = exe, Arguments = arguments }
};
};

using (TestDiskSpdExecutor executor = new TestDiskSpdExecutor(this.Dependencies, this.profileParameters))
{
executor.CreateWorkloadProcesses("diskspd.exe", "-b4K -r4K -t1 -o1 -w100", bareDisks, WorkloadProcessModel.SingleProcessPerDisk);
}

Assert.AreEqual(2, processCount);
// Each process targets exactly one drive via DiskSpd's #N syntax (derived from disk.Index).
Assert.IsTrue(capturedArguments[0].Contains(" #1"));
Assert.IsFalse(capturedArguments[0].Contains(" #2"));
Assert.IsTrue(capturedArguments[1].Contains(" #2"));
Assert.IsFalse(capturedArguments[1].Contains(" #1"));
}

[Test]
public void DiskSpdExecutorWithRawDiskTargetDoesNotAppendFilenamesToCommandLine()
{
Disk bareDisk = this.CreateDisk(1, PlatformID.Win32NT, os: false, @"\\.\PHYSICALDISK1");

this.profileParameters[nameof(DiskSpdExecutor.RawDiskTarget)] = true;

string capturedArguments = null;
this.ProcessManager.OnCreateProcess = (exe, arguments, workingDir) =>
{
capturedArguments = arguments;
return new InMemoryProcess
{
StartInfo = new ProcessStartInfo { FileName = exe, Arguments = arguments }
};
};

using (TestDiskSpdExecutor executor = new TestDiskSpdExecutor(this.Dependencies, this.profileParameters))
{
executor.CreateWorkloadProcesses(
"diskspd.exe",
"-b4K -r4K -t1 -o1 -w100",
new[] { bareDisk },
WorkloadProcessModel.SingleProcess);
}

Assert.IsNotNull(capturedArguments);
// DiskSpd's #N syntax is used -- derived from disk.Index=1.
// DiskSpd queries disk capacity via IOCTL; -c and \\.\PhysicalDriveN both cause errors.
Assert.IsTrue(capturedArguments.Contains(" #1"));
// No test-file extension should be present.
Assert.IsFalse(capturedArguments.Contains(".dat"));
}

[Test]
public void DiskSpdExecutorWithRawDiskTargetStoresDeviceNumberPathsInTestFiles()
{
// TestFiles is iterated by DeleteTestFilesAsync. For raw disk targets the paths must be
// the #N device number strings -- not file paths and not \\.\.PhysicalDriveN.
// File.Exists("#1") returns false, so DeleteTestFilesAsync becomes a correct no-op.
IEnumerable<Disk> bareDisks = new List<Disk>
{
this.CreateDisk(1, PlatformID.Win32NT, os: false, @"\\.\.PHYSICALDISK1"),
this.CreateDisk(2, PlatformID.Win32NT, os: false, @"\\.\.PHYSICALDISK2")
};

this.profileParameters[nameof(DiskSpdExecutor.RawDiskTarget)] = true;

this.ProcessManager.OnCreateProcess = (exe, arguments, workingDir) =>
new InMemoryProcess { StartInfo = new ProcessStartInfo { FileName = exe, Arguments = arguments } };

IEnumerable<DiskWorkloadProcess> processes;
using (TestDiskSpdExecutor executor = new TestDiskSpdExecutor(this.Dependencies, this.profileParameters))
{
processes = executor.CreateWorkloadProcesses(
"diskspd.exe", "-b4K -r4K -t1 -o1 -w100", bareDisks, WorkloadProcessModel.SingleProcessPerDisk).ToList();
}

Assert.AreEqual(2, processes.Count());
CollectionAssert.AreEqual(new[] { "#1" }, processes.ElementAt(0).TestFiles);
CollectionAssert.AreEqual(new[] { "#2" }, processes.ElementAt(1).TestFiles);
}

[Test]
public void DiskSpdExecutorRawDiskTargetDefaultsToFalse()
{
using (TestDiskSpdExecutor executor = new TestDiskSpdExecutor(this.Dependencies, this.profileParameters))
{
Assert.IsFalse(executor.RawDiskTarget);
}
}

[Test]
public void DiskSpdExecutorThrowsWhenBothRawDiskTargetAndDiskFillAreEnabled()
{
this.profileParameters[nameof(DiskSpdExecutor.RawDiskTarget)] = true;
this.profileParameters[nameof(DiskSpdExecutor.DiskFill)] = true;
this.profileParameters[nameof(DiskSpdExecutor.DiskFillSize)] = "500G";

using (TestDiskSpdExecutor executor = new TestDiskSpdExecutor(this.Dependencies, this.profileParameters))
{
WorkloadException exc = Assert.Throws<WorkloadException>(() => executor.Validate());
Assert.AreEqual(ErrorReason.InvalidProfileDefinition, exc.Reason);
}
}

private IEnumerable<Disk> SetupWorkloadScenario(
bool testRemoteDisks = false, bool testOSDisk = false, string processModel = WorkloadProcessModel.SingleProcess)
{
Expand Down
46 changes: 44 additions & 2 deletions src/VirtualClient/VirtualClient.Actions/DiskSpd/DiskSpdExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,25 @@ public string ProcessModel
}
}

/// <summary>
/// True/false whether to target the raw physical device path directly (e.g. <c>\\.\PhysicalDrive1</c>)
/// instead of a test file on a mounted volume. Use this for bare disk (unformatted) scenarios.
/// When enabled the <see cref="DiskFill"/> step is skipped and no test file path is appended to
/// the DiskSpd command line — the device path is passed instead.
/// </summary>
public bool RawDiskTarget
{
get
{
return this.Parameters.GetValue<bool>(nameof(this.RawDiskTarget), false);
}

set
{
this.Parameters[nameof(this.RawDiskTarget)] = value;
}
}

/// <summary>
/// The disk I/O queue depth to use for running disk I/O operations.
/// Default = 16.
Expand Down Expand Up @@ -311,8 +330,22 @@ protected override async Task CleanupAsync(EventContext telemetryContext, Cancel
/// <param name="disksToTest">The disks under test.</param>
protected DiskWorkloadProcess CreateWorkloadProcess(string executable, string commandArguments, string testedInstance, params Disk[] disksToTest)
{
string[] testFiles = disksToTest.Select(disk => this.GetTestFiles(disk.GetPreferredAccessPath(this.Platform))).ToArray();
string diskSpdArguments = $"{commandArguments} {string.Join(" ", testFiles)}";
string diskSpdArguments;
string[] testFiles;

if (this.RawDiskTarget)
{
// DiskSpd has a native syntax for targeting a physical drive by its index: #<N>.
// This is the correct format for raw physical disk access; DiskSpd uses
// IOCTL_DISK_GET_DRIVE_GEOMETRY_EX internally to determine the drive capacity.
testFiles = disksToTest.Select(disk => $"#{disk.Index}").ToArray();
diskSpdArguments = $"{commandArguments} {string.Join(" ", testFiles)}";
}
else
{
testFiles = disksToTest.Select(disk => this.GetTestFiles(disk.GetPreferredAccessPath(this.Platform))).ToArray();
diskSpdArguments = $"{commandArguments} {string.Join(" ", testFiles)}";
}

IProcessProxy process = this.SystemManagement.ProcessManager.CreateProcess(executable, diskSpdArguments);

Expand Down Expand Up @@ -667,6 +700,15 @@ protected override void Validate()
$"to be defined (e.g. 496G).",
ErrorReason.InvalidProfileDefinition);
}

if (this.RawDiskTarget && this.DiskFill)
{
throw new WorkloadException(
$"Invalid profile definition. The '{nameof(DiskSpdExecutor.DiskFill)}' option cannot be used together with " +
$"'{nameof(DiskSpdExecutor.RawDiskTarget)}'. Disk fill operations create test files on a mounted volume and are " +
$"not applicable to raw physical device access.",
ErrorReason.InvalidProfileDefinition);
}
}

private void CaptureMetrics(DiskWorkloadProcess workload, EventContext telemetryContext)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,5 +189,52 @@ public void GetDefaultMountPointNameExtensionUsesASpecificPrefixWhenProvided()
Assert.AreEqual(expectedMountPointName, actualMountPointName);
}
}

[Test]
public void GetPreferredAccessPathThrowsForBareDiskWithNoVolumes_Windows()
{
// GetPreferredAccessPath is for file-based workloads only. A disk with no volumes
// cannot be used for file I/O, so the original throw behavior is correct.
// Raw disk access bypasses this method entirely via RawDiskTarget in DiskSpdExecutor.
Disk bareDisk = this.fixture.CreateDisk(1, PlatformID.Win32NT, os: false, @"\\.\PHYSICALDISK1");

Assert.Throws<WorkloadException>(() => bareDisk.GetPreferredAccessPath(PlatformID.Win32NT));
}

[Test]
public void GetPreferredAccessPathThrowsForBareDiskWithNoVolumes_Unix()
{
// Same as Windows: a bare Linux disk with no volumes cannot be used for file I/O.
Disk bareDisk = this.fixture.CreateDisk(1, PlatformID.Unix, os: false, @"/dev/sdb");

Assert.Throws<WorkloadException>(() => bareDisk.GetPreferredAccessPath(PlatformID.Unix));
}

[Test]
public void GetPreferredAccessPathReturnsVolumeMountPointForFormattedNonOsDisk_Windows()
{
// A formatted non-OS Windows disk with a volume should return the volume access path.
this.disks = this.fixture.CreateDisks(PlatformID.Win32NT, true);
Disk dataDisk = this.disks.First(d => !d.IsOperatingSystem());

string path = dataDisk.GetPreferredAccessPath(PlatformID.Win32NT);
string expectedPath = dataDisk.Volumes.First().AccessPaths.First();

Assert.AreEqual(expectedPath, path);
}

[Test]
public void GetPreferredAccessPathReturnsVolumeMountPointForFormattedNonOsDisk_Unix()
{
this.disks = this.fixture.CreateDisks(PlatformID.Unix, true);
Disk dataDisk = this.disks.First(d => !d.IsOperatingSystem());

string path = dataDisk.GetPreferredAccessPath(PlatformID.Unix);
string expectedPath = dataDisk.Volumes
.OrderByDescending(v => v.SizeInBytes(PlatformID.Unix))
.First().AccessPaths.First();

Assert.AreEqual(expectedPath, path);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -524,5 +524,69 @@ public void DiskFiltersHandlesAnomaliesEncounters_1()
Assert.AreEqual("/dev/sdi", filteredDisks.ElementAt(30).DevicePath);
Assert.AreEqual("/dev/sdj", filteredDisks.ElementAt(31).DevicePath);
}

[Test]
public void DiskFiltersIncludeOfflineFilterKeepsOfflineDisksOnWindows()
{
// Arrange: create 4 disks; mark one as offline.
this.disks = this.mockFixture.CreateDisks(PlatformID.Win32NT, true);
this.disks.ElementAt(0).Properties["Status"] = "Online";
this.disks.ElementAt(1).Properties["Status"] = "Online";
this.disks.ElementAt(2).Properties["Status"] = "Offline (Policy)";
this.disks.ElementAt(3).Properties["Status"] = "Online";

// "none" alone would remove the offline disk; "none&IncludeOffline" should retain it.
string filterString = "none&IncludeOffline";
IEnumerable<Disk> result = DiskFilters.FilterDisks(this.disks, filterString, PlatformID.Win32NT);

Assert.AreEqual(4, result.Count());
Assert.IsTrue(result.Any(d => d.Properties["Status"].ToString().Contains("Offline")));
}

[Test]
public void DiskFiltersIncludeOfflineFilterIsCaseInsensitive()
{
this.disks = this.mockFixture.CreateDisks(PlatformID.Win32NT, true);
this.disks.ElementAt(1).Properties["Status"] = "Offline";

// All casing variants should be accepted.
foreach (string variant in new[] { "none&IncludeOffline", "none&includeoffline", "none&INCLUDEOFFLINE" })
{
IEnumerable<Disk> result = DiskFilters.FilterDisks(this.disks, variant, PlatformID.Win32NT);
Assert.AreEqual(4, result.Count(), $"Expected offline disk retained for filter '{variant}'");
}
}

[Test]
public void DiskFiltersWithoutIncludeOfflineDoesNotRetainOfflineDisksOnWindows()
{
this.disks = this.mockFixture.CreateDisks(PlatformID.Win32NT, true);
this.disks.ElementAt(2).Properties["Status"] = "Offline (Policy)";

// Default behaviour: the offline disk is excluded.
IEnumerable<Disk> result = DiskFilters.FilterDisks(this.disks, "none", PlatformID.Win32NT);

Assert.AreEqual(3, result.Count());
Assert.IsFalse(result.Any(d => d.Properties.ContainsKey("Status") &&
d.Properties["Status"].ToString().Contains("Offline")));
}

[Test]
public void DiskFiltersIncludeOfflineCanBeCombinedWithBiggestSizeFilter()
{
this.disks = this.mockFixture.CreateDisks(PlatformID.Win32NT, true);
// Make the offline disk the biggest.
this.disks.ElementAt(0).Properties["Size"] = "100 GB";
this.disks.ElementAt(1).Properties["Size"] = "100 GB";
this.disks.ElementAt(2).Properties["Size"] = "2000 GB"; // offline + biggest
this.disks.ElementAt(2).Properties["Status"] = "Offline";
this.disks.ElementAt(3).Properties["Size"] = "100 GB";

string filterString = "BiggestSize&IncludeOffline";
IEnumerable<Disk> result = DiskFilters.FilterDisks(this.disks, filterString, PlatformID.Win32NT);

Assert.AreEqual(1, result.Count());
Assert.IsTrue(object.ReferenceEquals(this.disks.ElementAt(2), result.First()));
}
}
}
19 changes: 18 additions & 1 deletion src/VirtualClient/VirtualClient.Contracts/DiskFilters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,15 @@ public static IEnumerable<Disk> FilterDisks(IEnumerable<Disk> disks, string filt
// filterName1:value1&filterName2:value2&filterDoesNotRequireValue&filter4:value4
List<string> filters = filterString.Split("&", StringSplitOptions.RemoveEmptyEntries).ToList();

// Allow callers to opt into keeping offline disks (e.g. bare/unformatted disks for raw disk I/O).
bool includeOffline = filters.Any(f => f.Trim().Equals(Filters.IncludeOffline, StringComparison.OrdinalIgnoreCase));

disks = DiskFilters.FilterStoragePathByPrefix(disks, platform);
disks = DiskFilters.FilterOfflineDisksOnWindows(disks, platform);
if (!includeOffline)
{
disks = DiskFilters.FilterOfflineDisksOnWindows(disks, platform);
}

disks = DiskFilters.FilterReadOnlyDisksOnWindows(disks, platform);

foreach (string filter in filters)
Expand Down Expand Up @@ -86,6 +93,10 @@ public static IEnumerable<Disk> FilterDisks(IEnumerable<Disk> disks, string filt
disks = DiskFilters.DiskPathFilter(disks, filterValue);
break;

case Filters.IncludeOffline:
// Already handled before the filter loop; treated as a no-op here.
break;

default:
throw new EnvironmentSetupException($"Disk filter '{filter}' is not supported.", ErrorReason.DiskInformationNotAvailable);
}
Expand Down Expand Up @@ -248,6 +259,12 @@ private static class Filters
/// Disk path filter.
/// </summary>
public const string DiskPath = "diskpath";

/// <summary>
/// Include offline disks filter. By default offline disks are excluded on Windows.
/// Use this filter to include them (e.g. bare/unformatted disks for raw disk I/O).
/// </summary>
public const string IncludeOffline = "includeoffline";
}
}
}
Loading