Skip to content

Commit ffb60f3

Browse files
ankitsharma-99Ankit Sharmabrdeyo
authored
Added SetDiskSanPolicy dependency to enable writable JBOD disks on Windows (#679)
Co-authored-by: Ankit Sharma <ankitshar@microsoft.com> Co-authored-by: Bryan DeYoung <35380894+brdeyo@users.noreply.github.com>
1 parent 9fc2a96 commit ffb60f3

8 files changed

Lines changed: 343 additions & 0 deletions

File tree

src/VirtualClient/VirtualClient.Core.UnitTests/WindowsDiskManagerTests.cs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1670,6 +1670,106 @@ public async Task WindowsDiskManagerGetsTheExpectedDisks_Scenario1()
16701670
actualDisks.ToList().ForEach(disk => Assert.IsTrue(disk.Volumes.Count() == 2));
16711671
}
16721672

1673+
[Test]
1674+
public async Task WindowsDiskManagerCallsTheExpectedDiskPartCommandsToSetSanPolicy()
1675+
{
1676+
this.testProcess.OnHasExited = () => true;
1677+
this.testProcess.OnStart = () => true;
1678+
1679+
List<string> expectedCommands = new List<string>
1680+
{
1681+
"san",
1682+
"san policy=onlineall",
1683+
"exit"
1684+
};
1685+
1686+
List<string> actualCommands = new List<string>();
1687+
1688+
this.standardInput.BytesWritten += (sender, data) =>
1689+
{
1690+
string input = data.ToString().Trim();
1691+
actualCommands.Add(input);
1692+
1693+
if (input == "san")
1694+
{
1695+
// Simulate a policy that is NOT already OnlineAll.
1696+
this.testProcess.StandardOutput.Append("SAN Policy : Offline Shared");
1697+
}
1698+
else if (input.Contains("san policy=onlineall"))
1699+
{
1700+
this.testProcess.StandardOutput.Append("DiskPart successfully changed the SAN policy for the current operating system.");
1701+
}
1702+
else if (input == "exit")
1703+
{
1704+
// Expected
1705+
}
1706+
else
1707+
{
1708+
Assert.Fail($"Unexpected command called: {input}");
1709+
}
1710+
};
1711+
1712+
await this.diskManager.SetSanPolicyAsync(CancellationToken.None).ConfigureAwait(false);
1713+
1714+
Assert.IsNotEmpty(actualCommands);
1715+
Assert.AreEqual(expectedCommands.Count, actualCommands.Count);
1716+
CollectionAssert.AreEquivalent(expectedCommands, actualCommands);
1717+
}
1718+
1719+
[Test]
1720+
public async Task WindowsDiskManagerSkipsSettingSanPolicyWhenItIsAlreadyOnlineAll()
1721+
{
1722+
this.testProcess.OnHasExited = () => true;
1723+
this.testProcess.OnStart = () => true;
1724+
1725+
// Only "san" and "exit" — no "san policy=onlineall".
1726+
List<string> expectedCommands = new List<string>
1727+
{
1728+
"san",
1729+
"exit"
1730+
};
1731+
1732+
List<string> actualCommands = new List<string>();
1733+
1734+
this.standardInput.BytesWritten += (sender, data) =>
1735+
{
1736+
string input = data.ToString().Trim();
1737+
actualCommands.Add(input);
1738+
1739+
if (input == "san")
1740+
{
1741+
// Simulate a policy that is already OnlineAll.
1742+
this.testProcess.StandardOutput.Append("SAN Policy : Online All");
1743+
}
1744+
else if (input == "exit")
1745+
{
1746+
// Expected
1747+
}
1748+
else
1749+
{
1750+
Assert.Fail($"Unexpected command called: {input}");
1751+
}
1752+
};
1753+
1754+
await this.diskManager.SetSanPolicyAsync(CancellationToken.None).ConfigureAwait(false);
1755+
1756+
Assert.IsNotEmpty(actualCommands);
1757+
Assert.AreEqual(expectedCommands.Count, actualCommands.Count);
1758+
CollectionAssert.AreEquivalent(expectedCommands, actualCommands);
1759+
}
1760+
1761+
[Test]
1762+
public void WindowsDiskManagerThrowsWhenSettingSanPolicyTimesOut()
1763+
{
1764+
this.testProcess.OnHasExited = () => true;
1765+
this.testProcess.OnStart = () => true;
1766+
1767+
// Do not write any response to standard output — the WaitForResponseAsync will time out.
1768+
this.standardInput.BytesWritten += (sender, data) => { };
1769+
1770+
Assert.ThrowsAsync<ProcessException>(() => this.diskManager.SetSanPolicyAsync(CancellationToken.None));
1771+
}
1772+
16731773
private class TestWindowsDiskManager : WindowsDiskManager
16741774
{
16751775
public TestWindowsDiskManager(ProcessManager processManager)

src/VirtualClient/VirtualClient.Core/DiskManager.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,8 @@ protected DiskManager(ILogger logger = null)
4545

4646
/// <inheritdoc/>
4747
public abstract Task<IEnumerable<Disk>> GetDisksAsync(CancellationToken cancellationToken);
48+
49+
/// <inheritdoc/>
50+
public abstract Task SetSanPolicyAsync(CancellationToken cancellationToken);
4851
}
4952
}

src/VirtualClient/VirtualClient.Core/IDiskManager.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,14 @@ public interface IDiskManager
3838
/// </summary>
3939
/// <param name="cancellationToken">A token that can be used to cancel the operation.</param>
4040
Task<IEnumerable<Disk>> GetDisksAsync(CancellationToken cancellationToken);
41+
42+
/// <summary>
43+
/// Sets the SAN (Storage Area Network) policy so that newly discovered disks are brought
44+
/// online and writable rather than left offline or read-only. On Windows this runs the
45+
/// DiskPart command <c>san policy=onlineall</c>, which prevents JBOD disks from being
46+
/// marked read-only by the OS. This operation is a no-op on Linux.
47+
/// </summary>
48+
/// <param name="cancellationToken">A token that can be used to cancel the operation.</param>
49+
Task SetSanPolicyAsync(CancellationToken cancellationToken);
4150
}
4251
}

src/VirtualClient/VirtualClient.Core/UnixDiskManager.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,15 @@ public override Task CreateMountPointAsync(DiskVolume volume, string mountPoint,
8181
});
8282
}
8383

84+
/// <summary>
85+
/// SAN policy is a Windows-only concept. This operation is a no-op on Linux/Unix.
86+
/// </summary>
87+
/// <param name="cancellationToken">A token that can be used to cancel the operation.</param>
88+
public override Task SetSanPolicyAsync(CancellationToken cancellationToken)
89+
{
90+
return Task.CompletedTask;
91+
}
92+
8493
/// <summary>
8594
/// Partitions and formats the disk for file system operations.
8695
/// </summary>

src/VirtualClient/VirtualClient.Core/WindowsDiskManager.cs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,90 @@ await process.WriteInput(command)
151151
});
152152
}
153153

154+
/// <summary>
155+
/// Sets the SAN policy to <c>onlineall</c> so that newly discovered JBOD disks are brought
156+
/// online and writable rather than remaining offline or read-only.
157+
/// </summary>
158+
/// <param name="cancellationToken">A token that can be used to cancel the operation.</param>
159+
public override Task SetSanPolicyAsync(CancellationToken cancellationToken)
160+
{
161+
EventContext context = EventContext.Persisted();
162+
163+
return this.Logger.LogMessageAsync($"{nameof(WindowsDiskManager)}.SetSanPolicy", context, async () =>
164+
{
165+
string command = string.Empty;
166+
int retries = -1;
167+
168+
try
169+
{
170+
await this.RetryPolicy.ExecuteAsync(async () =>
171+
{
172+
retries++;
173+
if (!cancellationToken.IsCancellationRequested)
174+
{
175+
using (IProcessProxy process = this.ProcessManager.CreateProcess("DiskPart", string.Empty))
176+
{
177+
try
178+
{
179+
process.Interactive();
180+
if (!process.Start())
181+
{
182+
throw new ProcessException("Failed to enter DiskPart session.", ErrorReason.DiskFormatFailed);
183+
}
184+
185+
// Query the current SAN policy first.
186+
command = "san";
187+
await process.WriteInput(command)
188+
.WaitForResponseAsync(@"SAN Policy\s*:", cancellationToken, timeout: TimeSpan.FromSeconds(30))
189+
.ConfigureAwait(false);
190+
191+
string sanOutput = process.StandardOutput.ToString();
192+
this.Logger.LogTraceMessage($"Current SAN policy output: {sanOutput}", context);
193+
194+
// Only set the policy if it is not already OnlineAll.
195+
// DiskPart reports the policy in the format: "SAN Policy : Online All"
196+
if (Regex.IsMatch(sanOutput, @"SAN Policy\s*:\s*Online All", RegexOptions.IgnoreCase))
197+
{
198+
this.Logger.LogTraceMessage("SAN policy is already set to OnlineAll. No change required.", context);
199+
}
200+
else
201+
{
202+
// Set SAN policy to OnlineAll so that newly discovered disks are
203+
// brought online and writable instead of remaining offline/read-only.
204+
command = "san policy=onlineall";
205+
await process.WriteInput(command)
206+
.WaitForResponseAsync(@"DiskPart successfully changed the SAN policy for the current operating system\.", cancellationToken, timeout: TimeSpan.FromSeconds(30))
207+
.ConfigureAwait(false);
208+
209+
this.Logger.LogTraceMessage("SAN policy set to OnlineAll.", context);
210+
}
211+
}
212+
catch (TimeoutException exc)
213+
{
214+
throw new ProcessException(
215+
$"Failed to set SAN policy. DiskPart command timed out (command={command}, retries={retries}). {Environment.NewLine}{process.StandardOutput}",
216+
exc,
217+
ErrorReason.DiskFormatFailed);
218+
}
219+
finally
220+
{
221+
process.WriteInput("exit");
222+
await Task.Delay(this.WaitTime).ConfigureAwait(false);
223+
context.AddProcessDetails(process.ToProcessDetails("diskpart"), "diskpartProcess");
224+
}
225+
}
226+
}
227+
}).ConfigureAwait(false);
228+
}
229+
catch (Win32Exception exc) when (exc.Message.Contains("requires elevation"))
230+
{
231+
throw new ProcessException(
232+
$"Requires elevated permissions. The current operation set requires the application to be run with administrator privileges.",
233+
ErrorReason.Unauthorized);
234+
}
235+
});
236+
}
237+
154238
/// <summary>
155239
/// Partitions and formats the disk for file system operations.
156240
/// </summary>
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace VirtualClient.Dependencies
5+
{
6+
using System;
7+
using System.Collections.Generic;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using Moq;
11+
using NUnit.Framework;
12+
using VirtualClient.Contracts;
13+
14+
[TestFixture]
15+
[Category("Unit")]
16+
public class SetDiskSanPolicyTests
17+
{
18+
private MockFixture mockFixture;
19+
20+
[Test]
21+
public async Task SetDiskSanPolicyCallsDiskManagerSetSanPolicyOnWindows()
22+
{
23+
this.mockFixture = new MockFixture();
24+
this.mockFixture.Setup(PlatformID.Win32NT);
25+
26+
using (SetDiskSanPolicy component = new SetDiskSanPolicy(this.mockFixture.Dependencies, this.mockFixture.Parameters))
27+
{
28+
await component.ExecuteAsync(CancellationToken.None);
29+
30+
this.mockFixture.DiskManager.Verify(
31+
mgr => mgr.SetSanPolicyAsync(It.IsAny<CancellationToken>()),
32+
Times.Once);
33+
}
34+
}
35+
36+
[Test]
37+
public async Task SetDiskSanPolicyDoesNotCallDiskManagerSetSanPolicyOnLinux()
38+
{
39+
this.mockFixture = new MockFixture();
40+
this.mockFixture.Setup(PlatformID.Unix);
41+
42+
using (SetDiskSanPolicy component = new SetDiskSanPolicy(this.mockFixture.Dependencies, this.mockFixture.Parameters))
43+
{
44+
await component.ExecuteAsync(CancellationToken.None);
45+
46+
this.mockFixture.DiskManager.Verify(
47+
mgr => mgr.SetSanPolicyAsync(It.IsAny<CancellationToken>()),
48+
Times.Never);
49+
}
50+
}
51+
52+
[Test]
53+
public void SetDiskSanPolicyPropagatesExceptionsThrownByDiskManagerOnWindows()
54+
{
55+
this.mockFixture = new MockFixture();
56+
this.mockFixture.Setup(PlatformID.Win32NT);
57+
58+
this.mockFixture.DiskManager
59+
.Setup(mgr => mgr.SetSanPolicyAsync(It.IsAny<CancellationToken>()))
60+
.ThrowsAsync(new ProcessException("DiskPart SAN policy command failed.", ErrorReason.DiskFormatFailed));
61+
62+
using (SetDiskSanPolicy component = new SetDiskSanPolicy(this.mockFixture.Dependencies, this.mockFixture.Parameters))
63+
{
64+
ProcessException exc = Assert.ThrowsAsync<ProcessException>(
65+
() => component.ExecuteAsync(CancellationToken.None));
66+
67+
Assert.AreEqual(ErrorReason.DiskFormatFailed, exc.Reason);
68+
}
69+
}
70+
}
71+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace VirtualClient.Dependencies
5+
{
6+
using System;
7+
using System.Collections.Generic;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using Microsoft.Extensions.DependencyInjection;
11+
using VirtualClient.Common.Extensions;
12+
using VirtualClient.Common.Telemetry;
13+
using VirtualClient.Contracts;
14+
15+
/// <summary>
16+
/// A dependency that sets the Windows SAN (Storage Area Network) policy to <c>OnlineAll</c>
17+
/// so that newly discovered JBOD disks are brought online and writable rather than being
18+
/// left offline or marked as read-only by the operating system.
19+
/// </summary>
20+
/// <remarks>
21+
/// On Windows, disks discovered through SAN controllers (including JBOD configurations)
22+
/// are sometimes left offline or marked read-only depending on the SAN policy in effect.
23+
/// Running the DiskPart commands <c>san</c> followed by <c>san policy=onlineall</c> configures
24+
/// Windows to automatically bring all newly discovered disks online and writable.
25+
/// This dependency is a no-op on Linux.
26+
/// </remarks>
27+
public class SetDiskSanPolicy : VirtualClientComponent
28+
{
29+
private ISystemManagement systemManagement;
30+
31+
/// <summary>
32+
/// Initializes a new instance of the <see cref="SetDiskSanPolicy"/> class.
33+
/// </summary>
34+
public SetDiskSanPolicy(IServiceCollection dependencies, IDictionary<string, IConvertible> parameters)
35+
: base(dependencies, parameters)
36+
{
37+
this.systemManagement = this.Dependencies.GetService<ISystemManagement>();
38+
}
39+
40+
/// <summary>
41+
/// Executes the DiskPart SAN policy change. Only runs on Windows; skipped on Linux.
42+
/// </summary>
43+
protected override async Task ExecuteAsync(EventContext telemetryContext, CancellationToken cancellationToken)
44+
{
45+
if (this.Platform == PlatformID.Win32NT)
46+
{
47+
await this.systemManagement.DiskManager.SetSanPolicyAsync(cancellationToken)
48+
.ConfigureAwait(false);
49+
}
50+
else
51+
{
52+
this.Logger.LogTraceMessage(
53+
$"{nameof(SetDiskSanPolicy)}: SAN policy is a Windows-only concept. Skipping on platform '{this.Platform}'.",
54+
telemetryContext);
55+
}
56+
}
57+
}
58+
}

src/VirtualClient/VirtualClient.TestFramework/InMemoryDiskManager.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,15 @@ public Task<IEnumerable<Disk>> GetDisksAsync(CancellationToken cancellationToken
129129
return Task.FromResult((IEnumerable<Disk>)this);
130130
}
131131

132+
/// <summary>
133+
/// No-op in the test/in-memory disk manager. SAN policy changes are Windows-only.
134+
/// </summary>
135+
/// <param name="cancellationToken">A token that can be used to cancel the operation.</param>
136+
public Task SetSanPolicyAsync(CancellationToken cancellationToken)
137+
{
138+
return Task.CompletedTask;
139+
}
140+
132141
private void AddVolumeToDisk(Disk disk, FileSystemType fileSystemType)
133142
{
134143
DiskVolume newVolume = null;

0 commit comments

Comments
 (0)