diff --git a/src/VirtualClient/VirtualClient.Actions.UnitTests/ScriptExecutor/PowershellExecutorTests.cs b/src/VirtualClient/VirtualClient.Actions.UnitTests/ScriptExecutor/PowershellExecutorTests.cs index 10a8d6e158..dd8a81a171 100644 --- a/src/VirtualClient/VirtualClient.Actions.UnitTests/ScriptExecutor/PowershellExecutorTests.cs +++ b/src/VirtualClient/VirtualClient.Actions.UnitTests/ScriptExecutor/PowershellExecutorTests.cs @@ -18,9 +18,9 @@ namespace VirtualClient.Actions [TestFixture] [Category("Unit")] - public class PowershellExecutorTests + public class PowerShellExecutorTests { - private static readonly string ExamplesDirectory = MockFixture.GetDirectory(typeof(PowershellExecutorTests), "Examples", "ScriptExecutor"); + private static readonly string ExamplesDirectory = MockFixture.GetDirectory(typeof(PowerShellExecutorTests), "Examples", "ScriptExecutor"); private MockFixture fixture; private DependencyPath mockPackage; @@ -35,7 +35,7 @@ public void SetupTest(PlatformID platform = PlatformID.Win32NT) this.fixture.Dependencies.RemoveAll>(); - this.exampleResults = File.ReadAllText(Path.Combine(PowershellExecutorTests.ExamplesDirectory, "validJsonExample.json")); + this.exampleResults = File.ReadAllText(Path.Combine(PowerShellExecutorTests.ExamplesDirectory, "validJsonExample.json")); this.fixture.FileSystem.Setup(fe => fe.File.Exists(It.IsAny())) .Returns(true); @@ -127,46 +127,92 @@ public void SetupTest(PlatformID platform = PlatformID.Win32NT) this.fixture.Parameters = new Dictionary() { - { nameof(PowershellExecutor.PackageName), "workloadPackage" }, - { nameof(PowershellExecutor.Scenario), "GenericScriptWorkload" }, - { nameof(PowershellExecutor.CommandLine), "parameter1 parameter2" }, - { nameof(PowershellExecutor.ScriptPath), "genericScript.ps1" }, - { nameof(PowershellExecutor.LogPaths), "*.log;*.txt;*.json" }, - { nameof(PowershellExecutor.ToolName), "GenericTool" }, - { nameof(PowershellExecutor.UsePwsh), false } + { nameof(PowerShellExecutor.PackageName), "workloadPackage" }, + { nameof(PowerShellExecutor.Scenario), "GenericScriptWorkload" }, + { nameof(PowerShellExecutor.CommandLine), "parameter1 parameter2" }, + { nameof(PowerShellExecutor.ScriptPath), "genericScript.ps1" }, + { nameof(PowerShellExecutor.LogPaths), "*.log;*.txt;*.json" }, + { nameof(PowerShellExecutor.ToolName), "GenericTool" } }; this.fixture.ProcessManager.OnCreateProcess = (command, arguments, directory) => this.fixture.Process; } [Test] - [TestCase(PlatformID.Win32NT, @"\win-x64", @"genericScript.ps1", true, false, "powershell")] - [TestCase(PlatformID.Win32NT, @"\win-x64", @"genericScript.ps1", false, false, "powershell")] - [TestCase(PlatformID.Win32NT, @"\win-x64", @"genericScript.ps1", true, true, "pwsh")] - [TestCase(PlatformID.Win32NT, @"\win-x64", @"genericScript.ps1", false, true, "pwsh")] + [TestCase(@"genericScript.ps1", "powershell")] + [TestCase(@"genericScript.ps1", "powershell")] + [TestCase(@"genericScript.ps1", "powershell")] + [TestCase(@"genericScript.ps1", "powershell.exe")] + [TestCase(@"genericScript.ps1", "PowerShell.exe")] + [TestCase(@"genericScript.ps1", @"C:\Any\Custom\Location\powershell.exe")] + [TestCase(@"genericScript.ps1", "pwsh")] + [TestCase(@"genericScript.ps1", "pwsh.exe")] + [TestCase(@"genericScript.ps1", @"C:\Any\Custom\Location\pwsh.exe")] [Platform(Exclude = "Unix,Linux,MacOsX")] - public async Task PowershellExecutorExecutesTheCorrectWorkloadCommands(PlatformID platform, string platformSpecificPath, string command, bool runElevated, bool usePwsh, string executorType) + public async Task PowershellExecutorExecutesTheCorrectWorkloadCommands_Windows_Scenarios(string command, string executable) { - this.SetupTest(platform); - this.fixture.Parameters[nameof(PowershellExecutor.RunElevated)] = runElevated; - this.fixture.Parameters[nameof(PowershellExecutor.ScriptPath)] = command; - this.fixture.Parameters[nameof(PowershellExecutor.UsePwsh)] = usePwsh; + this.SetupTest(PlatformID.Win32NT); + this.fixture.Parameters[nameof(PowerShellExecutor.ScriptPath)] = command; + this.fixture.Parameters[nameof(PowerShellExecutor.Executable)] = executable; - string fullCommand = $"{this.mockPackage.Path}{platformSpecificPath}\\{command} parameter1 parameter2"; + string fullCommand = $"{this.mockPackage.Path}\\win-x64\\{command} parameter1 parameter2"; - using (TestPowershellExecutor executor = new TestPowershellExecutor(this.fixture)) + using (TestPowerShellExecutor executor = new TestPowerShellExecutor(this.fixture)) { bool commandExecuted = false; + await executor.InitializeAsync(EventContext.None, CancellationToken.None); + string workingDirectory = executor.ExecutableDirectory; + + string expectedCommand = $"{executable} -ExecutionPolicy Bypass -NoProfile -NonInteractive -WindowStyle Hidden -Command \"cd '{workingDirectory}';{fullCommand}\""; + this.fixture.ProcessManager.OnCreateProcess = (exe, arguments, workingDirectory) => + { + if(expectedCommand == $"{exe} {arguments}") + { + commandExecuted = true; + } + + return new InMemoryProcess + { + StartInfo = new ProcessStartInfo + { + FileName = exe, + Arguments = arguments + }, + ExitCode = 0, + OnStart = () => true, + OnHasExited = () => true + }; + }; - await executor.InitializeAsync(EventContext.None, CancellationToken.None) - .ConfigureAwait(false); + await executor.ExecuteAsync(CancellationToken.None); + Assert.DoesNotThrowAsync(() => executor.ExecuteAsync(CancellationToken.None)); + Assert.IsTrue(commandExecuted); + } + } + + [Test] + [TestCase("genericScript.ps1", "pwsh")] + [TestCase("genericScript.ps1", @"/home/any/custom/location/pwsh")] + [TestCase("genericScript.ps1", "sudo pwsh")] + public async Task PowershellExecutorExecutesTheCorrectWorkloadCommands_Unix_Scenarios(string command, string executable) + { + this.SetupTest(PlatformID.Unix); + this.fixture.Parameters[nameof(PowerShellExecutor.ScriptPath)] = command; + this.fixture.Parameters[nameof(PowerShellExecutor.Executable)] = executable; + + string fullCommand = $"{this.mockPackage.Path}/linux-x64/{command} parameter1 parameter2"; + + using (TestPowerShellExecutor executor = new TestPowerShellExecutor(this.fixture)) + { + bool commandExecuted = false; + await executor.InitializeAsync(EventContext.None, CancellationToken.None); string workingDirectory = executor.ExecutableDirectory; - string expectedCommand = $"{executorType} -ExecutionPolicy Bypass -NoProfile -NonInteractive -WindowStyle Hidden -Command \"cd '{workingDirectory}';{fullCommand}\""; + string expectedCommand = $"{executable} -ExecutionPolicy Bypass -NoProfile -NonInteractive -WindowStyle Hidden -Command \"cd '{workingDirectory}';{fullCommand}\""; this.fixture.ProcessManager.OnCreateProcess = (exe, arguments, workingDirectory) => { - if(expectedCommand == $"{exe} {arguments}") + if (expectedCommand == $"{exe} {arguments}") { commandExecuted = true; } @@ -184,8 +230,7 @@ await executor.InitializeAsync(EventContext.None, CancellationToken.None) }; }; - await executor.ExecuteAsync(CancellationToken.None) - .ConfigureAwait(false); + await executor.ExecuteAsync(CancellationToken.None); Assert.DoesNotThrowAsync(() => executor.ExecuteAsync(CancellationToken.None)); Assert.IsTrue(commandExecuted); @@ -193,14 +238,13 @@ await executor.ExecuteAsync(CancellationToken.None) } [Test] - [TestCase(PlatformID.Win32NT, @"\win-x64\")] - public void PowershellExecutorDoesNotThrowWhenTheWorkloadDoesNotProduceValidMetricsFile(PlatformID platform, string platformSpecificPath) + public void PowershellExecutorDoesNotThrowWhenTheWorkloadDoesNotProduceValidMetricsFile() { - this.SetupTest(platform); - this.fixture.File.Setup(fe => fe.Exists($"{this.mockPackage.Path}{platformSpecificPath}test-metrics.json")) + this.SetupTest(PlatformID.Win32NT); + this.fixture.File.Setup(fe => fe.Exists($@"{this.mockPackage.Path}\win-x64\test-metrics.json")) .Returns(false); - using (TestPowershellExecutor executor = new TestPowershellExecutor(this.fixture)) + using (TestPowerShellExecutor executor = new TestPowerShellExecutor(this.fixture)) { this.fixture.ProcessManager.OnCreateProcess = (command, arguments, directory) => this.fixture.Process; @@ -208,13 +252,29 @@ public void PowershellExecutorDoesNotThrowWhenTheWorkloadDoesNotProduceValidMetr } } - private class TestPowershellExecutor : PowershellExecutor + private class TestPowerShellExecutor : PowerShellExecutor { - public TestPowershellExecutor(MockFixture fixture) + public TestPowerShellExecutor(MockFixture fixture) : base(fixture.Dependencies, fixture.Parameters) { } + public new string ExecutablePath + { + get + { + return base.ExecutablePath; + } + } + + public new string ExecutableDirectory + { + get + { + return base.ExecutableDirectory; + } + } + public new Task InitializeAsync(EventContext telemetryContext, CancellationToken cancellationToken) { return base.InitializeAsync(telemetryContext, cancellationToken); diff --git a/src/VirtualClient/VirtualClient.Actions.UnitTests/ScriptExecutor/ScriptExecutorTests.cs b/src/VirtualClient/VirtualClient.Actions.UnitTests/ScriptExecutor/ScriptExecutorTests.cs index d47ffa30c1..920763b9c9 100644 --- a/src/VirtualClient/VirtualClient.Actions.UnitTests/ScriptExecutor/ScriptExecutorTests.cs +++ b/src/VirtualClient/VirtualClient.Actions.UnitTests/ScriptExecutor/ScriptExecutorTests.cs @@ -327,9 +327,9 @@ public void ScriptExecutorMovesTheLogFilesToCorrectDirectory_Win(PlatformID plat this.mockFixture.File.Setup(fe => fe.Move(It.IsAny(), It.IsAny(), true)) .Callback((sourcePath, destinitionPath, overwrite) => { - destinitionPathCorrect = Regex.IsMatch( - destinitionPath, - $"{logsDir}.{this.mockFixture.Parameters["ToolName"].ToString().ToLower()}.{this.mockFixture.Parameters["Scenario"].ToString().ToLower()}"); + destinitionPathCorrect = destinitionPath.StartsWith(this.mockFixture.Combine( + logsDir, + this.mockFixture.Parameters["ToolName"].ToString().ToLower())); sourcePathCorrect = Regex.IsMatch( sourcePath, @@ -363,9 +363,9 @@ public void ScriptExecutorMovesTheLogFilesToCorrectDirectory_Unix(PlatformID pla this.mockFixture.File.Setup(fe => fe.Move(It.IsAny(), It.IsAny(), true)) .Callback((sourcePath, destinitionPath, overwrite) => { - destinitionPathCorrect = Regex.IsMatch( - destinitionPath, - $"{logsDir}.{this.mockFixture.Parameters["ToolName"].ToString().ToLower()}.{this.mockFixture.Parameters["Scenario"].ToString().ToLower()}"); + destinitionPathCorrect = destinitionPath.StartsWith(this.mockFixture.Combine( + logsDir, + this.mockFixture.Parameters["ToolName"].ToString().ToLower())); sourcePathCorrect = Regex.IsMatch( sourcePath, @@ -387,6 +387,22 @@ public TestScriptExecutor(MockFixture fixture) { } + public new string ExecutablePath + { + get + { + return base.ExecutablePath; + } + } + + public new string ExecutableDirectory + { + get + { + return base.ExecutableDirectory; + } + } + public new Task InitializeAsync(EventContext telemetryContext, CancellationToken cancellationToken) { return base.InitializeAsync(telemetryContext, cancellationToken); diff --git a/src/VirtualClient/VirtualClient.Actions/ScriptExecutor/PowershellExecutor.cs b/src/VirtualClient/VirtualClient.Actions/ScriptExecutor/PowershellExecutor.cs index 464f2fed19..ee0bb6acea 100644 --- a/src/VirtualClient/VirtualClient.Actions/ScriptExecutor/PowershellExecutor.cs +++ b/src/VirtualClient/VirtualClient.Actions/ScriptExecutor/PowershellExecutor.cs @@ -5,6 +5,7 @@ namespace VirtualClient.Actions { using System; using System.Collections.Generic; + using System.Globalization; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -15,26 +16,49 @@ namespace VirtualClient.Actions /// /// The Generic Script executor for Powershell /// - public class PowershellExecutor : ScriptExecutor + public class PowerShellExecutor : ScriptExecutor { + private const string PowerShellExecutableName = "powershell"; + private const string PowerShell7ExecutableName = "pwsh"; + /// - /// Constructor for + /// Constructor for /// /// Provides required dependencies to the component. /// Parameters defined in the profile or supplied on the command line. - public PowershellExecutor(IServiceCollection dependencies, IDictionary parameters) + public PowerShellExecutor(IServiceCollection dependencies, IDictionary parameters) : base(dependencies, parameters) { + this.ApplyBackwardsCompatibility(); + } + + /// + /// The name (or path) of the PowerShell executable to use (e.g. pwsh, PowerShell.exe) + /// + public string Executable + { + get + { + return this.Parameters.GetValue(nameof(this.Executable), PowerShellExecutor.PowerShellExecutableName); + } } /// /// The parameter specifies whether to use pwsh, by default it is false /// - public bool UsePwsh + private bool UsePwsh { get { - return this.Parameters.GetValue(nameof(this.UsePwsh), false); + // TODO: + // Remove this property entirely. + // + // This is an indirect way to get to the name of the PowerShell executable (pwsh vs. PowerShell.exe). + // There is no reason to do so indirectly. There are additional benefits to allowing the user to simply + // specify the executable name: + // 1) The usage is just as easy and the outcome is the same. + // 2) A full path to a specific version of PowerShell can be supplied (i.e. flexibility for other use cases). + throw new NotSupportedException("Design Correction. The PowerShell executable name or path should be specified explicitly (e.g. pwsh, PowerShell.exe)."); } } @@ -45,26 +69,45 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel { using (BackgroundOperations profiling = BackgroundOperations.BeginProfiling(this, cancellationToken)) { - string command = this.UsePwsh ? "pwsh" : "powershell"; - string commandArguments = SensitiveData.ObscureSecrets( - $"-ExecutionPolicy Bypass -NoProfile -NonInteractive -WindowStyle Hidden -Command \"cd '{this.ExecutableDirectory}';{this.ExecutablePath} {this.CommandLine}\""); + string command = this.Executable; + + // Handle nested quotation mark parsing pitfalls. + string commandLine = this.CommandLine.Replace("\"", "\\\""); + string executablePath = this.ExecutablePath.Replace("\"", "\\\""); + string commandArguments = $"-ExecutionPolicy Bypass -NoProfile -NonInteractive -WindowStyle Hidden -Command \"cd '{this.ExecutableDirectory}';{executablePath} {commandLine}\""; telemetryContext .AddContext(nameof(command), command) - .AddContext(nameof(commandArguments), commandArguments); + .AddContext(nameof(commandArguments), SensitiveData.ObscureSecrets(commandArguments)); using (IProcessProxy process = await this.ExecuteCommandAsync(command, commandArguments, this.ExecutableDirectory, telemetryContext, cancellationToken, this.RunElevated)) { if (!cancellationToken.IsCancellationRequested) { - await this.LogProcessDetailsAsync(process, telemetryContext, this.ToolName); - process.ThrowIfWorkloadFailed(); - - await this.CaptureMetricsAsync(process, telemetryContext, cancellationToken); - await this.CaptureLogsAsync(cancellationToken); + try + { + await this.LogProcessDetailsAsync(process, telemetryContext, this.ToolName); + process.ThrowIfWorkloadFailed(); + } + finally + { + await this.CaptureMetricsAsync(process, telemetryContext, cancellationToken); + await this.CaptureLogsAsync(cancellationToken); + } } } } } + + private void ApplyBackwardsCompatibility() + { + if (this.Parameters.TryGetValue(nameof(this.UsePwsh), out IConvertible usePwsh)) + { + bool usePowerShell7 = usePwsh.ToBoolean(CultureInfo.InvariantCulture); + this.Parameters[nameof(this.Executable)] = usePowerShell7 + ? PowerShellExecutor.PowerShell7ExecutableName + : PowerShellExecutor.PowerShellExecutableName; + } + } } } \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Actions/ScriptExecutor/PythonExecutor.cs b/src/VirtualClient/VirtualClient.Actions/ScriptExecutor/PythonExecutor.cs index a4a2d1f82e..ffa005c755 100644 --- a/src/VirtualClient/VirtualClient.Actions/ScriptExecutor/PythonExecutor.cs +++ b/src/VirtualClient/VirtualClient.Actions/ScriptExecutor/PythonExecutor.cs @@ -58,17 +58,22 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel telemetryContext .AddContext(nameof(command), command) - .AddContext(nameof(commandArguments), commandArguments); + .AddContext(nameof(commandArguments), SensitiveData.ObscureSecrets(commandArguments)); using (IProcessProxy process = await this.ExecuteCommandAsync(command, commandArguments, this.ExecutableDirectory, telemetryContext, cancellationToken, this.RunElevated)) { if (!cancellationToken.IsCancellationRequested) { - await this.LogProcessDetailsAsync(process, telemetryContext, this.ToolName); - process.ThrowIfWorkloadFailed(); - - await this.CaptureMetricsAsync(process, telemetryContext, cancellationToken); - await this.CaptureLogsAsync(cancellationToken); + try + { + await this.LogProcessDetailsAsync(process, telemetryContext, this.ToolName); + process.ThrowIfWorkloadFailed(); + } + finally + { + await this.CaptureMetricsAsync(process, telemetryContext, cancellationToken); + await this.CaptureLogsAsync(cancellationToken); + } } } } diff --git a/src/VirtualClient/VirtualClient.Actions/ScriptExecutor/ScriptExecutor.cs b/src/VirtualClient/VirtualClient.Actions/ScriptExecutor/ScriptExecutor.cs index aa95e08e8d..8b18b71f46 100644 --- a/src/VirtualClient/VirtualClient.Actions/ScriptExecutor/ScriptExecutor.cs +++ b/src/VirtualClient/VirtualClient.Actions/ScriptExecutor/ScriptExecutor.cs @@ -12,6 +12,7 @@ namespace VirtualClient.Actions using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; + using MimeMapping; using Polly; using VirtualClient.Common; using VirtualClient.Common.Extensions; @@ -24,6 +25,7 @@ namespace VirtualClient.Actions /// public class ScriptExecutor : VirtualClientComponent { + private const string MetricsFileName = "test-metrics.json"; private IFileSystem fileSystem; private ISystemManagement systemManagement; @@ -52,7 +54,7 @@ public string CommandLine /// /// The Script Path can be an absolute Path, or be relative to the Virtual Client Executable - /// or be relative to platformspecific package if the script is downloaded using DependencyPackageInstallation. + /// or be relative to platform specific package if the script is downloaded using DependencyPackageInstallation. /// public string ScriptPath { @@ -65,11 +67,12 @@ public string ScriptPath /// /// The Regex Based, semi-colon separated relative log file/Folder Paths /// - public string LogPaths + public IEnumerable LogPaths { get { - return this.Parameters.GetValue(nameof(this.LogPaths)); + this.Parameters.TryGetCollection(nameof(this.LogPaths), out IEnumerable logPaths); + return logPaths; } } @@ -109,17 +112,22 @@ public bool RunElevated /// /// The full path to the script executable. /// - public string ExecutablePath { get; set; } + protected string ExecutablePath { get; set; } /// /// The path to the directory containing script executable. /// - public string ExecutableDirectory { get; set; } + protected string ExecutableDirectory { get; set; } /// /// A retry policy to apply to file access/move operations. /// - public IAsyncPolicy FileOperationsRetryPolicy { get; set; } = RetryPolicies.FileOperations; + protected IAsyncPolicy FileOperationsRetryPolicy { get; set; } = RetryPolicies.FileOperations; + + /// + /// The full path to the expected metrics file (when it exists). + /// + protected string MetricsFilePath { get; set; } /// /// Initializes the environment for execution of the provided script. @@ -129,14 +137,25 @@ protected override async Task InitializeAsync(EventContext telemetryContext, Can await this.EvaluateParametersAsync(cancellationToken); string scriptFileLocation = string.Empty; - if (!string.IsNullOrWhiteSpace(this.PackageName)) + if (PlatformSpecifics.IsFullyQualifiedPath(this.ScriptPath)) { - DependencyPath workloadPackage = await this.GetPlatformSpecificPackageAsync(this.PackageName, cancellationToken); - this.ExecutablePath = this.fileSystem.Path.GetFullPath(this.fileSystem.Path.Combine(workloadPackage.Path, this.ScriptPath)); + this.ExecutablePath = this.ScriptPath; } - else if (this.fileSystem.Path.IsPathRooted(this.ScriptPath)) + else if (!string.IsNullOrWhiteSpace(this.PackageName)) { - this.ExecutablePath = this.ScriptPath; + DependencyPath workloadPackage = await this.GetPackageAsync(this.PackageName, cancellationToken); + DependencyPath platformSpecificPackage = this.ToPlatformSpecificPath(workloadPackage, this.Platform, this.CpuArchitecture); + + if (this.fileSystem.Directory.Exists(platformSpecificPackage.Path)) + { + // Package is separated into platform-specific folders (e.g. win-x64, linux-arm64). + this.ExecutablePath = this.fileSystem.Path.GetFullPath(this.fileSystem.Path.Combine(platformSpecificPackage.Path, this.ScriptPath)); + } + else + { + // Package does not have platform-specific folders. + this.ExecutablePath = this.fileSystem.Path.GetFullPath(this.fileSystem.Path.Combine(workloadPackage.Path, this.ScriptPath)); + } } else { @@ -151,6 +170,12 @@ protected override async Task InitializeAsync(EventContext telemetryContext, Can ErrorReason.WorkloadDependencyMissing); } + this.MetricsFilePath = this.Combine(this.ExecutableDirectory, ScriptExecutor.MetricsFileName); + if (this.fileSystem.File.Exists(this.MetricsFilePath)) + { + this.fileSystem.File.Delete(this.MetricsFilePath); + } + this.ExecutableDirectory = this.fileSystem.Path.GetDirectoryName(this.ExecutablePath); this.ToolName = string.IsNullOrWhiteSpace(this.ToolName) ? $"{this.fileSystem.Path.GetFileNameWithoutExtension(this.ExecutablePath)}" : this.ToolName; await this.systemManagement.MakeFileExecutableAsync(this.ExecutablePath, this.Platform, cancellationToken); @@ -164,21 +189,26 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel using (BackgroundOperations profiling = BackgroundOperations.BeginProfiling(this, cancellationToken)) { string command = this.ExecutablePath; - string commandArguments = SensitiveData.ObscureSecrets(this.CommandLine); + string commandArguments = this.CommandLine; telemetryContext .AddContext(nameof(command), command) - .AddContext(nameof(commandArguments), commandArguments); + .AddContext(nameof(commandArguments), SensitiveData.ObscureSecrets(commandArguments)); using (IProcessProxy process = await this.ExecuteCommandAsync(command, commandArguments, this.ExecutableDirectory, telemetryContext, cancellationToken, this.RunElevated)) { if (!cancellationToken.IsCancellationRequested) { - await this.LogProcessDetailsAsync(process, telemetryContext, this.ToolName, logToFile: true); - process.ThrowIfWorkloadFailed(); - - await this.CaptureMetricsAsync(process, telemetryContext, cancellationToken); - await this.CaptureLogsAsync(cancellationToken); + try + { + await this.LogProcessDetailsAsync(process, telemetryContext, this.ToolName); + process.ThrowIfWorkloadFailed(); + } + finally + { + await this.CaptureMetricsAsync(process, telemetryContext, cancellationToken); + await this.CaptureLogsAsync(cancellationToken); + } } } } @@ -197,30 +227,32 @@ protected async Task CaptureMetricsAsync(IProcessProxy process, EventContext tel this.MetadataContract.Apply(telemetryContext); - string metricsFilePath = this.Combine(this.ExecutableDirectory, "test-metrics.json"); bool metricsFileFound = false; try { - if (this.fileSystem.File.Exists(metricsFilePath)) + if (this.fileSystem.File.Exists(this.MetricsFilePath)) { metricsFileFound = true; - telemetryContext.AddContext(nameof(metricsFilePath), metricsFilePath); - string results = await this.fileSystem.File.ReadAllTextAsync(metricsFilePath); - - JsonMetricsParser parser = new JsonMetricsParser(results, this.Logger, telemetryContext); - IList workloadMetrics = parser.Parse(); - - this.Logger.LogMetrics( - this.ToolName, - this.MetricScenario ?? this.Scenario, - process.StartTime, - process.ExitTime, - workloadMetrics, - null, - process.FullCommand(), - this.Tags, - telemetryContext); + telemetryContext.AddContext("metricsFilePath", this.MetricsFilePath); + string results = await this.fileSystem.File.ReadAllTextAsync(this.MetricsFilePath); + + if (!string.IsNullOrWhiteSpace(results)) + { + JsonMetricsParser parser = new JsonMetricsParser(results, this.Logger, telemetryContext); + IList workloadMetrics = parser.Parse(); + + this.Logger.LogMetrics( + this.ToolName, + (this.MetricScenario ?? this.Scenario) ?? "Script", + process.StartTime, + process.ExitTime, + workloadMetrics, + null, + process.FullCommand(), + this.Tags, + telemetryContext); + } } } finally @@ -233,42 +265,42 @@ protected async Task CaptureMetricsAsync(IProcessProxy process, EventContext tel /// /// Captures the workload logs based on LogFiles parameter of ScriptExecutor. /// All the files inmatching sub-folders and all the matching files along with metrics file will be moved to the - /// central Virtual Client logs directory. If the content store (--cs) argument is used with Virtual Client, then - /// the captured logs will also be uploaded to blob content store. + /// central Virtual Client logs directory. /// protected async Task CaptureLogsAsync(CancellationToken cancellationToken) { // e.g. // /logs/anytool/executecustomscript1 // /logs/anytool/executecustomscript2 - string destinitionLogsDir = this.PlatformSpecifics.GetLogsPath(this.ToolName.ToLower(), (this.Scenario ?? "customscript").ToLower()); - if (!this.fileSystem.Directory.Exists(destinitionLogsDir)) - { - this.fileSystem.Directory.CreateDirectory(destinitionLogsDir); - } + string destinitionLogsDir = this.Combine(this.GetLogDirectory(this.ToolName), DateTime.UtcNow.ToString("yyyy-MM-dd_hh-mm-ss")); - foreach (string logPath in this.LogPaths.Split(";")) + if (this.LogPaths?.Any() == true) { - if (string.IsNullOrWhiteSpace(logPath)) + foreach (string logPath in this.LogPaths) { - continue; - } + if (string.IsNullOrWhiteSpace(logPath)) + { + continue; + } - string fullLogPath = this.fileSystem.Path.GetFullPath(this.fileSystem.Path.Combine(this.ExecutableDirectory, logPath)); + string fullLogPath = this.fileSystem.Path.GetFullPath(this.fileSystem.Path.Combine(this.ExecutableDirectory, logPath)); - // Check for Matching Sub-Directories - if (this.fileSystem.Directory.Exists(fullLogPath)) - { - foreach (string logFilePath in this.fileSystem.Directory.GetFiles(fullLogPath, "*", SearchOption.AllDirectories)) + // Check for Matching Sub-Directories + if (this.fileSystem.Directory.Exists(fullLogPath)) { - this.RequestUploadAndMoveToLogsDirectory(logFilePath, destinitionLogsDir, cancellationToken, sourceRoot: fullLogPath); + foreach (string logFilePath in this.fileSystem.Directory.GetFiles(fullLogPath, "*", SearchOption.AllDirectories)) + { + var logs = await this.MoveLogsAsync(logFilePath, destinitionLogsDir, cancellationToken, sourceRootDirectory: fullLogPath); + await this.RequestLogUploadsAsync(logs); + } } - } - // Check for Matching FileNames - foreach (string logFilePath in this.fileSystem.Directory.GetFiles(this.ExecutableDirectory, logPath, SearchOption.AllDirectories)) - { - await this.RequestUploadAndMoveToLogsDirectory(logFilePath, destinitionLogsDir, cancellationToken); + // Check for Matching FileNames + foreach (string logFilePath in this.fileSystem.Directory.GetFiles(this.ExecutableDirectory, logPath, SearchOption.AllDirectories)) + { + var logs = await this.MoveLogsAsync(logFilePath, destinitionLogsDir, cancellationToken); + await this.RequestLogUploadsAsync(logs); + } } } @@ -276,74 +308,82 @@ protected async Task CaptureLogsAsync(CancellationToken cancellationToken) string metricsFilePath = this.Combine(this.ExecutableDirectory, "test-metrics.json"); if (this.fileSystem.File.Exists(metricsFilePath)) { - await this.RequestUploadAndMoveToLogsDirectory(metricsFilePath, destinitionLogsDir, cancellationToken); + var logs = await this.MoveLogsAsync(metricsFilePath, destinitionLogsDir, cancellationToken); + await this.RequestLogUploadsAsync(logs); } } /// - /// Requests a file upload. + /// Requests a file upload for each of the log file paths provided. /// - protected Task RequestUpload(string logPath) + protected async Task RequestLogUploadsAsync(IEnumerable logPaths) { - FileUploadDescriptor descriptor = this.CreateFileUploadDescriptor( - new FileContext( - this.fileSystem.FileInfo.New(logPath), - HttpContentType.PlainText, - Encoding.UTF8.WebName, - this.ExperimentId, - this.AgentId, - this.ToolName, - this.Scenario, - null, - this.Roles?.FirstOrDefault())); + if (logPaths?.Any() == true && this.TryGetContentStoreManager(out IBlobManager blobManager)) + { + foreach (string logPath in logPaths) + { + FileUploadDescriptor descriptor = this.CreateFileUploadDescriptor( + new FileContext( + this.fileSystem.FileInfo.New(logPath), + MimeUtility.GetMimeMapping(logPath), + Encoding.UTF8.WebName, + this.ExperimentId, + this.AgentId, + this.ToolName, + this.Scenario, + null, + this.Roles?.FirstOrDefault())); - return this.RequestFileUploadAsync(descriptor); + await this.RequestFileUploadAsync(descriptor); + } + } } /// /// Move the log files to central logs directory (retaining source directory structure) and Upload to Content Store. /// - private async Task RequestUploadAndMoveToLogsDirectory( - string sourcePath, - string destinitionDirectory, - CancellationToken cancellationToken, - string sourceRoot = null) + private async Task> MoveLogsAsync(string sourcePath, string destinationDirectory, CancellationToken cancellationToken, string sourceRootDirectory = null) { - if (string.Equals(sourcePath, this.ExecutablePath)) - { - return; - } - - string destPath = sourcePath; - await (this.FileOperationsRetryPolicy ?? Policy.NoOpAsync()).ExecuteAsync(() => + List targetLogs = new List(); + if (!string.Equals(sourcePath, this.ExecutablePath)) { - string fileName = Path.GetFileName(sourcePath); - - if (!string.IsNullOrEmpty(sourceRoot)) - { - // Compute relative path from sourceRoot to sourcePath - string relativePath = this.fileSystem.Path.GetRelativePath(sourceRoot, sourcePath); - string destDir = this.fileSystem.Path.Combine(destinitionDirectory, this.fileSystem.Path.GetDirectoryName(relativePath)); - if (!this.fileSystem.Directory.Exists(destDir)) - { - this.fileSystem.Directory.CreateDirectory(destDir); - } - - destPath = this.fileSystem.Path.Combine(destDir, BlobDescriptor.SanitizeBlobPath($"{DateTime.UtcNow:O}".Replace('.', '-') + "-" + fileName)); - } - else + if (!this.fileSystem.Directory.Exists(destinationDirectory)) { - destPath = this.Combine(destinitionDirectory, BlobDescriptor.SanitizeBlobPath($"{DateTime.UtcNow:O}".Replace('.', '-') + "-" + fileName)); + this.fileSystem.Directory.CreateDirectory(destinationDirectory); } - this.fileSystem.File.Move(sourcePath, destPath, true); - return Task.CompletedTask; - }); - - if (this.TryGetContentStoreManager(out IBlobManager blobManager)) - { - await this.RequestUpload(destPath); + string destPath = sourcePath; + await (this.FileOperationsRetryPolicy ?? Policy.NoOpAsync()).ExecuteAsync(async () => + { + await Task.Run(() => + { + string fileName = Path.GetFileName(sourcePath); + + if (!string.IsNullOrEmpty(sourceRootDirectory)) + { + // Compute relative path from sourceRoot to sourcePath + string relativePath = this.fileSystem.Path.GetRelativePath(sourceRootDirectory, sourcePath); + string destDir = this.fileSystem.Path.Combine(destinationDirectory, this.fileSystem.Path.GetDirectoryName(relativePath)); + + if (!this.fileSystem.Directory.Exists(destDir)) + { + this.fileSystem.Directory.CreateDirectory(destDir); + } + + destPath = this.Combine(destDir, fileName); + } + else + { + destPath = this.Combine(destinationDirectory, fileName); + } + + this.fileSystem.File.Move(sourcePath, destPath, true); + targetLogs.Add(destPath); + }); + }); } + + return targetLogs; } } } \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Contracts.UnitTests/PlatformSpecificsTests.cs b/src/VirtualClient/VirtualClient.Contracts.UnitTests/PlatformSpecificsTests.cs index 9bf4997f7f..363e86da92 100644 --- a/src/VirtualClient/VirtualClient.Contracts.UnitTests/PlatformSpecificsTests.cs +++ b/src/VirtualClient/VirtualClient.Contracts.UnitTests/PlatformSpecificsTests.cs @@ -304,6 +304,37 @@ public void TheListOfSupportedProcessorArchitecturesMatchesExpected(Architecture } } + [Test] + [TestCase(@"C:\", true)] + [TestCase(@"C:\Users", true)] + [TestCase(@"C:\Users\User\VirtualClient", true)] + [TestCase(@"C:/", true)] + [TestCase(@"C:/Users", true)] + [TestCase(@"C:/Users/User/VirtualClient", true)] + [TestCase(@"\\Server01", false)] + [TestCase(@"\\Server01\VirtualClient", false)] + [TestCase(@"\Users\User\VirtualClient", false)] + public void IsFullyQualifiedPathHandlesExpectedRangeOfPaths_Windows_Scenarios(string path, bool isFullyQualified) + { + Assert.AreEqual( + isFullyQualified, + PlatformSpecifics.IsFullyQualifiedPath(path), + $"Path validation failed: {path}"); + } + + [Test] + [TestCase(@"/", true)] + [TestCase(@"/home/user", true)] + [TestCase(@"/home/user/virtualclient", true)] + [TestCase(@"./virtualclient", false)] + public void IsFullyQualifiedPathHandlesExpectedRangeOfPaths_Unix_Scenarios(string path, bool isFullyQualified) + { + Assert.AreEqual( + isFullyQualified, + PlatformSpecifics.IsFullyQualifiedPath(path), + $"Path validation failed: {path}"); + } + [Test] [Platform(Include ="Win")] public void CanResolveRelativePathsCorrectly() diff --git a/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs b/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs index 896b6b6871..16cf4b40a3 100644 --- a/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs +++ b/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientLoggingExtensionsTests.cs @@ -6,8 +6,11 @@ namespace VirtualClient.Contracts using System; using System.Collections.Generic; using System.Diagnostics; + using System.IO; + using System.IO.Abstractions; using System.Linq; using System.Text; + using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; diff --git a/src/VirtualClient/VirtualClient.Contracts/PlatformSpecifics.cs b/src/VirtualClient/VirtualClient.Contracts/PlatformSpecifics.cs index e9e96fb403..9dc058d497 100644 --- a/src/VirtualClient/VirtualClient.Contracts/PlatformSpecifics.cs +++ b/src/VirtualClient/VirtualClient.Contracts/PlatformSpecifics.cs @@ -246,11 +246,11 @@ public static string GetProfileName(string profileName, PlatformID platform, Arc /// local file system. /// /// The path to evaluate. - /// True if the path is a fully qualified path (e.g. C:\Users\any\path, home/user/any/path). False if not. + /// True if the path is a fully qualified path (e.g. C:\Users\any\path, /home/user/any/path). False if not. public static bool IsFullyQualifiedPath(string path) { path.ThrowIfNull(nameof(path)); - return Regex.IsMatch(path, "[A-Z]+:\\\\|^/", RegexOptions.IgnoreCase); + return Regex.IsMatch(path.Trim(), @"^[A-Z]{1}:[\\\\/]|^\/", RegexOptions.IgnoreCase); } /// diff --git a/src/VirtualClient/VirtualClient.Core/VirtualClientComponentExtensions.cs b/src/VirtualClient/VirtualClient.Core/VirtualClientComponentExtensions.cs index faa94db045..9b35d9056a 100644 --- a/src/VirtualClient/VirtualClient.Core/VirtualClientComponentExtensions.cs +++ b/src/VirtualClient/VirtualClient.Core/VirtualClientComponentExtensions.cs @@ -125,9 +125,11 @@ public static async Task ExecuteCommandAsync( } } + string safeArguments = SensitiveData.ObscureSecrets(commandArguments); + EventContext relatedContext = telemetryContext.Clone() .AddContext(nameof(command), command) - .AddContext(nameof(commandArguments), commandArguments) + .AddContext(nameof(commandArguments), safeArguments) .AddContext(nameof(workingDirectory), workingDirectory) .AddContext(nameof(runElevated), runElevated); @@ -146,11 +148,10 @@ public static async Task ExecuteCommandAsync( } component.CleanupTasks.Add(() => process.SafeKill(component.Logger)); - component.Logger.LogTraceMessage($"Executing: {command} {SensitiveData.ObscureSecrets(commandArguments)}".Trim(), relatedContext); + component.Logger.LogTraceMessage($"Executing: {command} {safeArguments}".Trim(), relatedContext); beforeExecution?.Invoke(process); - await process.StartAndWaitAsync(cancellationToken) - .ConfigureAwait(false); + await process.StartAndWaitAsync(cancellationToken); } return process; diff --git a/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs b/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs index 838e456974..e4584ba650 100644 --- a/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs +++ b/src/VirtualClient/VirtualClient.Core/VirtualClientLoggingExtensions.cs @@ -27,6 +27,26 @@ public static class VirtualClientLoggingExtensions internal static readonly Regex PathReservedCharacterExpression = new Regex(@"[""<>:|?*\\/]+", RegexOptions.Compiled); private static readonly Semaphore FileAccessLock = new Semaphore(1, 1); + /// + /// Returns the target log directory for the component and tool name. + /// + /// The component requesting the logging. + /// The name of the toolset running in the process. + public static string GetLogDirectory(this VirtualClientComponent component, string toolName = null) + { + string[] possibleLogFolderNames = new string[] + { + toolName, + component.LogFolderName, + component.TypeName + }; + + string logFolderName = VirtualClientLoggingExtensions.GetSafeFileName(possibleLogFolderNames.First(name => !string.IsNullOrWhiteSpace(name)), false); + string logDirectory = component.PlatformSpecifics.GetLogsPath(logFolderName.ToLowerInvariant().RemoveWhitespace()); + + return logDirectory; + } + /// /// Captures the details of the process including standard output, standard error and exit codes to /// telemetry and log files on the system. @@ -250,15 +270,6 @@ internal static async Task LogProcessDetailsToFileAsync(this VirtualClientCompon if (component.Dependencies.TryGetService(out IFileSystem fileSystem) && component.Dependencies.TryGetService(out PlatformSpecifics specifics)) { - string[] possibleLogFolderNames = new string[] - { - component.LogFolderName, - processDetails.ToolName, - component.TypeName - }; - - string logFolderName = VirtualClientLoggingExtensions.GetSafeFileName(possibleLogFolderNames.First(name => !string.IsNullOrWhiteSpace(name)), false); - string[] possibleLogFileNames = new string[] { logFileName, @@ -268,7 +279,7 @@ internal static async Task LogProcessDetailsToFileAsync(this VirtualClientCompon component.TypeName }; - string logDirectory = specifics.GetLogsPath(logFolderName.ToLowerInvariant().RemoveWhitespace()); + string logDirectory = component.GetLogDirectory(processDetails.ToolName); string standardizedLogFileName = VirtualClientLoggingExtensions.GetSafeFileName(possibleLogFileNames.First(name => !string.IsNullOrWhiteSpace(name)), timestamped); if (string.IsNullOrWhiteSpace(Path.GetExtension(standardizedLogFileName))) @@ -386,7 +397,7 @@ await RetryPolicies.FileOperations.ExecuteAsync(async () => if (upload && component.TryGetContentStoreManager(out IBlobManager blobManager)) { - string effectiveToolName = logFolderName; + string effectiveToolName = fileSystem.Path.GetDirectoryName(logDirectory); FileContext fileContext = new FileContext( fileSystem.FileInfo.New(logFilePath), diff --git a/src/VirtualClient/VirtualClient.Dependencies/ExecuteCommand.cs b/src/VirtualClient/VirtualClient.Dependencies/ExecuteCommand.cs index 803047437a..03231b53d0 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/ExecuteCommand.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/ExecuteCommand.cs @@ -151,7 +151,7 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel if (!cancellationToken.IsCancellationRequested) { - await this.LogProcessDetailsAsync(process, telemetryContext, toolName: this.LogFolderName, logFileName: this.LogFileName, timestamped: this.LogTimestamped); + await this.LogProcessDetailsAsync(process, telemetryContext, logFileName: this.LogFileName, timestamped: this.LogTimestamped); process.ThrowIfComponentOperationFailed(this.ComponentType); } } diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs index 7ddc9999ef..19d9a5b32a 100644 --- a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs +++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs @@ -1514,38 +1514,31 @@ private static IEnumerable ParseProfiles(ArgumentRes foreach (Token argument in parsedResult.Tokens) { - string profileReference = OptionFactory.GetValue(argument); + string profileReference = OptionFactory.GetValue(argument)?.Trim(); if (PlatformSpecifics.IsFullyQualifiedPath(profileReference)) { profiles.Add(new DependencyProfileReference(profileReference)); } - else if (!Uri.TryCreate(profileReference, UriKind.Absolute, out Uri profileUri) - && !EndpointUtility.IsCustomConnectionString(profileReference) - && !EndpointUtility.IsStorageAccountConnectionString(profileReference)) + else if (Uri.TryCreate(profileReference, UriKind.Absolute, out Uri profileUri) + || EndpointUtility.IsCustomConnectionString(profileReference) + || EndpointUtility.IsStorageAccountConnectionString(profileReference)) { - if (PlatformSpecifics.IsFullyQualifiedPath(profileReference)) + profiles.Add(EndpointUtility.CreateProfileReference(profileReference, certificateManager)); + } + else + { + string directoryName = Path.GetDirectoryName(profileReference); + if (string.IsNullOrWhiteSpace(directoryName)) { profiles.Add(new DependencyProfileReference(profileReference)); } else { - string directoryName = Path.GetDirectoryName(profileReference); - if (string.IsNullOrWhiteSpace(directoryName)) - { - profiles.Add(new DependencyProfileReference(profileReference)); - } - else - { - string fullPath = Path.GetFullPath(profileReference); - profiles.Add(new DependencyProfileReference(fullPath)); - } - } - } - else - { - profiles.Add(EndpointUtility.CreateProfileReference(profileReference, certificateManager)); - } + string fullPath = Path.GetFullPath(profileReference); + profiles.Add(new DependencyProfileReference(fullPath)); + } + } } return profiles; diff --git a/src/VirtualClient/VirtualClient.TestFramework.UnitTests/MockFixtureTests.cs b/src/VirtualClient/VirtualClient.TestFramework.UnitTests/MockFixtureTests.cs index 0911b68c3c..ae397380db 100644 --- a/src/VirtualClient/VirtualClient.TestFramework.UnitTests/MockFixtureTests.cs +++ b/src/VirtualClient/VirtualClient.TestFramework.UnitTests/MockFixtureTests.cs @@ -34,7 +34,7 @@ public void MockFixtureGetDirectoryNameHandlesWindowsStylePaths(string path, str [TestCase(null, null)] [TestCase(" ", null)] [TestCase("/", null)] - [TestCase("/home", null)] + [TestCase("/home", "/")] [TestCase("/home/path", "/home")] [TestCase("/home/path/", "/home")] [TestCase("/home/path/file1.log", "/home/path")] @@ -108,5 +108,45 @@ public void MockFixtureApiClientResponsesHandleBeingDisposed_UpdateStateAsync() Assert.DoesNotThrow(() => content = response2.Content.ReadAsStringAsync().Result); Assert.IsNotNull(content); } + + [Test] + [TestCase(null, null)] + [TestCase(" ", null)] + [TestCase("C:", null)] + [TestCase(@"C:\", null)] + [TestCase(@"C:\path", @"C:\")] + [TestCase(@"C:\any\path", @"C:\any")] + [TestCase(@"C:\any/path", @"C:\any")] + [TestCase(@"C:\any\path\file1.log", @"C:\any\path")] + [TestCase(@"any\path\file1.log", @"any\path")] + [TestCase(@"\any\path\file1.log", @"\any\path")] + [TestCase(@"~\any\path\file1.log", @"~\any\path")] + public void MockFixtureSetsUpExpectedBehaviorForDirectoryGetDirectoryName_WindowsStylePaths(string path, string expectedDirectoryName) + { + MockFixture mockFixture = new MockFixture(); + mockFixture.Setup(System.PlatformID.Win32NT); + + string actualDirectoryName = mockFixture.FileSystem.Object.Path.GetDirectoryName(path); + Assert.AreEqual(expectedDirectoryName, actualDirectoryName); + } + + [Test] + [TestCase(null, null)] + [TestCase(" ", null)] + [TestCase("/", null)] + [TestCase("/home", "/")] + [TestCase("/home/path", "/home")] + [TestCase("/home/path/", "/home")] + [TestCase("/home/path/file1.log", "/home/path")] + [TestCase("~/path/file1.log", "~/path")] + [TestCase(@"/home\path\file1.log", @"/home\path")] + public void MockFixtureSetsUpExpectedBehaviorForDirectoryGetDirectoryName_UnixStylePaths(string path, string expectedDirectoryName) + { + MockFixture mockFixture = new MockFixture(); + mockFixture.Setup(System.PlatformID.Unix); + + string actualDirectoryName = mockFixture.FileSystem.Object.Path.GetDirectoryName(path); + Assert.AreEqual(expectedDirectoryName, actualDirectoryName); + } } } diff --git a/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs b/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs index 1bcc148ad1..0aadadf5ab 100644 --- a/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs +++ b/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs @@ -5,7 +5,6 @@ namespace VirtualClient { using System; using System.Collections.Generic; - using System.IO; using System.IO.Abstractions; using System.Linq; using System.Net; @@ -16,7 +15,6 @@ namespace VirtualClient using System.Threading; using System.Threading.Tasks; using AutoFixture; - using MathNet.Numerics.Distributions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -43,21 +41,22 @@ public class MockFixture : Fixture /// The path to the directory where the test binaries (.dlls) exist. This can be used to mimic the "runtime/working" directory /// of the Virtual Client for the purpose of testing dependencies expected to exist in that directory. /// - public static readonly string TestAssemblyDirectory = Path.GetDirectoryName(MockFixture.TestAssembly.Location); + public static readonly string TestAssemblyDirectory = System.IO.Path.GetDirectoryName(MockFixture.TestAssembly.Location); /// /// The path to the directory where test example files can be found. Note that this requires the /// test project to copy the files to a directory called 'Examples'. /// - public static readonly string ExamplesDirectory = Path.Combine(TestAssemblyDirectory, "Examples"); + public static readonly string ExamplesDirectory = System.IO.Path.Combine(TestAssemblyDirectory, "Examples"); /// /// The path to the directory where test test resource/example files can be found. Note that this requires the /// test project to copy the files to a directory called 'TestResources'. /// - public static readonly string TestResourcesDirectory = Path.Combine(TestAssemblyDirectory, "TestResources"); + public static readonly string TestResourcesDirectory = System.IO.Path.Combine(TestAssemblyDirectory, "TestResources"); private static readonly char[] PathDividers = new char[] { '\\', '/' }; + private static readonly Regex UnixTopLevelFolderExpression = new Regex(@"^(\/[^\/]+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex WindowsVolumeExpression = new Regex(@"^([a-z]\:[\\/])(.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); private string experimentId; @@ -274,11 +273,11 @@ public static string GetDirectory(Type testClassType, params string[] pathSegmen { if (pathSegments?.Any() != true) { - return MockFixture.CurrentPlatform.Combine(Path.GetDirectoryName(Assembly.GetAssembly(testClassType).Location)); + return MockFixture.CurrentPlatform.Combine(System.IO.Path.GetDirectoryName(Assembly.GetAssembly(testClassType).Location)); } else { - return MockFixture.CurrentPlatform.Combine(new string[] { Path.GetDirectoryName(Assembly.GetAssembly(testClassType).Location) }.Union(pathSegments).ToArray()); + return MockFixture.CurrentPlatform.Combine(new string[] { System.IO.Path.GetDirectoryName(Assembly.GetAssembly(testClassType).Location) }.Union(pathSegments).ToArray()); } } @@ -293,6 +292,19 @@ public static string GetDirectory(Type testClassType, params string[] pathSegmen /// The directory name for the path. public static string GetDirectoryName(string path) { + // Matching .NET Behaviors: + // Path.GetDirectoryName(null) = null + // Path.GetDirectoryName(string.Empty) = null + // Path.GetDirectoryName("C:\") = null + // Path.GetDirectoryName("C:\Any") = C:\ + // Path.GetDirectoryName("C:\Any\Folder") = C:\Any + // Path.GetDirectoryName("C:\Any\Folder\ToFile.log") = C:\Any\Folder + // + // Path.GetDirectoryName("/") = null + // Path.GetDirectoryName("/home") = / + // Path.GetDirectoryName("/home/any") = /home + // Path.GetDirectoryName("/home/any/ToFile.log") = /home/any + string directoryName = null; if (!string.IsNullOrWhiteSpace(path)) { @@ -305,10 +317,19 @@ public static string GetDirectoryName(string path) // Paths with more than 1 segment. // // e.g. + // /home -> / // /home/path -> /home // C:\Users\Path -> C:\Users directoryName = effectivePath.Substring(0, lastIndexOfPathDivider); } + else if (MockFixture.UnixTopLevelFolderExpression.IsMatch(path)) + { + // Top-level folder on Linux. + // + // e.g. + // /home -> / + directoryName = "/"; + } else { // e.g. @@ -441,7 +462,7 @@ public virtual MockFixture Setup(PlatformID platform, Architecture architecture { Mock mockFile = new Mock(); - mockFile.Setup(file => file.Name).Returns(Path.GetFileName(path)); + mockFile.Setup(file => file.Name).Returns(System.IO.Path.GetFileName(path)); mockFile.Setup(file => file.CreationTime).Returns(DateTime.Now); mockFile.Setup(file => file.CreationTimeUtc).Returns(DateTime.UtcNow); mockFile.Setup(file => file.Length).Returns(12345); @@ -450,6 +471,9 @@ public virtual MockFixture Setup(PlatformID platform, Architecture architecture return mockFile.Object; }); + this.FileSystem.Setup(fs => fs.Path.GetDirectoryName(It.IsAny())) + .Returns(path => MockFixture.GetDirectoryName(path)); + this.DiskManager = new Mock(mockBehavior); this.Logger = new InMemoryLogger(); this.FirewallManager = new Mock(mockBehavior); diff --git a/src/VirtualClient/VirtualClient.UnitTests/OptionFactoryTests.cs b/src/VirtualClient/VirtualClient.UnitTests/OptionFactoryTests.cs index 921e78c680..f8a7c0e364 100644 --- a/src/VirtualClient/VirtualClient.UnitTests/OptionFactoryTests.cs +++ b/src/VirtualClient/VirtualClient.UnitTests/OptionFactoryTests.cs @@ -15,7 +15,9 @@ namespace VirtualClient using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Options; using Moq; using NUnit.Framework; using VirtualClient.Contracts.Extensibility;