diff --git a/VERSION b/VERSION index 1a7df8126a..7e68b1fb42 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.29 \ No newline at end of file +2.1.30 \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Actions/GeekBench/GeekbenchExecutor.cs b/src/VirtualClient/VirtualClient.Actions/GeekBench/GeekbenchExecutor.cs index a5096d366e..fa9cdf49ec 100644 --- a/src/VirtualClient/VirtualClient.Actions/GeekBench/GeekbenchExecutor.cs +++ b/src/VirtualClient/VirtualClient.Actions/GeekBench/GeekbenchExecutor.cs @@ -275,11 +275,6 @@ private Task ExecuteWorkloadAsync(string pathToExe, string commandLineArguments, await this.LogProcessDetailsAsync(process, telemetryContext, this.PackageName, logToFile: true); process.ThrowIfWorkloadFailed(); - if (process.StandardError.Length > 0) - { - process.ThrowOnStandardError(errorReason: ErrorReason.WorkloadFailed); - } - string standardOutput = process.StandardOutput.ToString(); this.CaptureMetrics(process, standardOutput, commandLineArguments, telemetryContext, cancellationToken); } diff --git a/src/VirtualClient/VirtualClient.Common.UnitTests/SensitiveDataTests.cs b/src/VirtualClient/VirtualClient.Common.UnitTests/SensitiveDataTests.cs index 7b386cde98..404dee8cef 100644 --- a/src/VirtualClient/VirtualClient.Common.UnitTests/SensitiveDataTests.cs +++ b/src/VirtualClient/VirtualClient.Common.UnitTests/SensitiveDataTests.cs @@ -151,15 +151,6 @@ public void ObscureSecretsObfuscatesSasUriSignatures_Scenario1() Assert.AreEqual(dataContainingSecrets.Item2, obscuredString); } - [Test] - public void ObscureSecretsObfuscatesSasUriSignatures_Scenario2() - { - Tuple dataContainingSecrets = SensitiveDataTests.GetSasUriPairScenario2(); - string obscuredString = SensitiveData.ObscureSecrets(dataContainingSecrets.Item1); - - Assert.AreEqual(dataContainingSecrets.Item2, obscuredString); - } - [Test] [TestCase("Password=AnySecretHereae09g34YT112", "Password=...")] [TestCase("Password AnySecretHereae09g34YT112", "Password ...")] @@ -216,6 +207,18 @@ public void ObscureSecretsHandlesCasesWhereThePasswordTermIsASubstringThatIsPart Assert.AreEqual(expectedString, obscuredString); } + [Test] + [TestCase("user@10.2.3.5;pass_;wor;d", "user@10.2.3.5;...")] + [TestCase("user@10.2.3.5;pass__w@rd", "user@10.2.3.5;...")] + [TestCase("user@machine@somewhere;pass", "user@machine@somewhere;...")] + [TestCase("user@machine@somewhere;pass;_w@rd", "user@machine@somewhere;...")] + [TestCase("user@2001:db8:85a3:0:0:8a2e:370:7334;pass;_w@rd", "user@2001:db8:85a3:0:0:8a2e:370:7334;...")] + public void ObscureSecretsObfuscatesPasswordsInAgentSshConnections(string originalString, string expectedString) + { + string obscuredString = SensitiveData.ObscureSecrets(originalString); + Assert.AreEqual(expectedString, obscuredString); + } + [Test] public void ObscureSecretsObfuscatesSecretsThatMatchAGivenRegularExpression_Scenario1() { diff --git a/src/VirtualClient/VirtualClient.Common/SensitiveData.cs b/src/VirtualClient/VirtualClient.Common/SensitiveData.cs index 0252914299..d1a0440822 100644 --- a/src/VirtualClient/VirtualClient.Common/SensitiveData.cs +++ b/src/VirtualClient/VirtualClient.Common/SensitiveData.cs @@ -30,6 +30,10 @@ public static class SensitiveData // of expressions to allow the handling of special cases (e.g. delimited key/value pair groups (e.g. Password=s@me,val;ue,,,Property1=Value1). new Regex("(?<=Password[=\x20]+\"*)(?:,{0,2}[\x21\x23-\x2B\x2D-\x7E]+,{0,2}[\x21\x23-\x2B\x2D-\x7E]+)+\"*", RegexOptions.IgnoreCase), new Regex("(?<=Pwd[=\x20]+\"*)(?:,{0,2}[\x21\x23-\x2B\x2D-\x7E]+,{0,2}[\x21\x23-\x2B\x2D-\x7E]+)+\"*", RegexOptions.IgnoreCase), + + // Agent SSH connections allow for passwords + // (e.g. user@10.2.3.5;pass_;wor;d). + new Regex(@"[0-9a-z_\-\. ]+@[^;]+;([\x20-\x7E]+)", RegexOptions.IgnoreCase) }; /// diff --git a/src/VirtualClient/VirtualClient.Contracts/AliasAttribute.cs b/src/VirtualClient/VirtualClient.Contracts/AliasAttribute.cs index 15b47b8085..eac9163225 100644 --- a/src/VirtualClient/VirtualClient.Contracts/AliasAttribute.cs +++ b/src/VirtualClient/VirtualClient.Contracts/AliasAttribute.cs @@ -10,7 +10,7 @@ namespace VirtualClient.Contracts /// /// Defines one or more aliases for a given class for reflection support. /// - [AttributeUsage(validOn: AttributeTargets.Class)] + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] public class AliasAttribute : Attribute { /// diff --git a/src/VirtualClient/VirtualClient.Contracts/ComponentTypeCache.cs b/src/VirtualClient/VirtualClient.Contracts/ComponentTypeCache.cs index 39e75abf55..fe610e28e2 100644 --- a/src/VirtualClient/VirtualClient.Contracts/ComponentTypeCache.cs +++ b/src/VirtualClient/VirtualClient.Contracts/ComponentTypeCache.cs @@ -8,6 +8,7 @@ namespace VirtualClient.Contracts using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; + using System.Net; using System.Reflection; using Microsoft.Extensions.Logging; using VirtualClient.Common.Extensions; diff --git a/src/VirtualClient/VirtualClient.Contracts/Constants.cs b/src/VirtualClient/VirtualClient.Contracts/Constants.cs index 91ffbe7bac..f8a9472129 100644 --- a/src/VirtualClient/VirtualClient.Contracts/Constants.cs +++ b/src/VirtualClient/VirtualClient.Contracts/Constants.cs @@ -150,21 +150,6 @@ public static class EnvironmentVariable /// public const string PATH = nameof(PATH); - /// - /// Name = SDK_EVENTHUB_CONNECTION - /// - public const string SDK_EVENTHUB_CONNECTION = nameof(SDK_EVENTHUB_CONNECTION); - - /// - /// Name = SDK_PACKAGES_CONNECTION - /// - public const string SDK_PACKAGES_CONNECTION = nameof(SDK_PACKAGES_CONNECTION); - - /// - /// Name = SDK_PACKAGES_DIR - /// - public const string SDK_PACKAGES_DIR = nameof(SDK_PACKAGES_DIR); - /// /// Name = SUDO_USER /// diff --git a/src/VirtualClient/VirtualClient.Contracts/PlatformSpecifics.cs b/src/VirtualClient/VirtualClient.Contracts/PlatformSpecifics.cs index 5a745beaff..e9e96fb403 100644 --- a/src/VirtualClient/VirtualClient.Contracts/PlatformSpecifics.cs +++ b/src/VirtualClient/VirtualClient.Contracts/PlatformSpecifics.cs @@ -149,7 +149,7 @@ public PlatformSpecifics(PlatformID platform, Architecture architecture, string public string StateDirectory { get; set; } /// - /// The directory where scripts related to workloads exist. + /// The directory where temp files can be saved. /// public string TempDirectory { get; set; } @@ -280,8 +280,8 @@ public static string ResolveRelativePaths(string text) { // Ensure that relative working directory paths are fully expanded. Preserve case-sensitivity // to avoid anomalies on Linux. - string relativeDirectory = match.Value; - resolved = resolved.Replace(relativeDirectory, Path.GetFullPath(relativeDirectory), StringComparison.Ordinal); + string relativePath = match.Value; + resolved = resolved.Replace(relativePath, Path.GetFullPath(relativePath), StringComparison.Ordinal); } } } diff --git a/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs b/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs index cbaa65e3f7..c5eb9a5ea0 100644 --- a/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs +++ b/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs @@ -377,7 +377,7 @@ protected set /// placeholders and well-known terms to be replaced in the values of the parameters before /// execution of workloads, monitors or dependencies. /// - public bool ParametersEvaluated { get; internal set; } + public bool ParametersEvaluated { get; protected set; } /// /// The OS/system platform (e.g. Windows, Unix). @@ -659,6 +659,28 @@ public static bool IsSupported(VirtualClientComponent component) return platformSupported && component.IsSupported(); } + /// + /// Evaluates each of the parameters provided to the component to replace + /// supported placeholder expressions (e.g. {PackagePath:anytool} -> replace with path to 'anytool' package). + /// + /// A token that can be used to cancel the operations. + /// Forces the evaluation of the parameters for scenarios where re-evaluation is necessary after an initial pass. Default = false. + public async Task EvaluateParametersAsync(CancellationToken cancellationToken, bool force = false) + { + if (!this.ParametersEvaluated || force) + { + if (this.Parameters?.Any() == true) + { + if (this.Dependencies.TryGetService(out IExpressionEvaluator evaluator)) + { + await evaluator.EvaluateAsync(this.Dependencies, this.Parameters, cancellationToken); + } + } + + this.ParametersEvaluated = true; + } + } + /// /// When overriden in a derived class, executes the component logic. /// diff --git a/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponentExtensions.cs b/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponentExtensions.cs index b19412975a..25d151315f 100644 --- a/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponentExtensions.cs +++ b/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponentExtensions.cs @@ -220,31 +220,6 @@ public static IEnumerable CreateFileUploadDescriptors(this return descriptors; } - /// - /// Evaluates each of the parameters provided to the component to replace - /// supported placeholder expressions (e.g. {PackagePath:anytool} -> replace with path to 'anytool' package). - /// - /// The component whose parameters to evaluate. - /// A token that can be used to cancel the operations. - /// Forces the evaluation of the parameters for scenarios where re-evaluation is necessary after an initial pass. Default = false. - public static async Task EvaluateParametersAsync(this VirtualClientComponent component, CancellationToken cancellationToken, bool force = false) - { - component.ThrowIfNull(nameof(component)); - - if (!component.ParametersEvaluated || force) - { - if (component.Parameters?.Any() == true) - { - if (component.Dependencies.TryGetService(out IExpressionEvaluator evaluator)) - { - await evaluator.EvaluateAsync(component.Dependencies, component.Parameters, cancellationToken); - } - } - - component.ParametersEvaluated = true; - } - } - /// /// Returns the client instance defined in the environment layout provided to the Virtual Client /// whose ID matches. diff --git a/src/VirtualClient/VirtualClient.Contracts/VirtualClientRuntime.cs b/src/VirtualClient/VirtualClient.Contracts/VirtualClientRuntime.cs index 4064cb8014..8a2352de0a 100644 --- a/src/VirtualClient/VirtualClient.Contracts/VirtualClientRuntime.cs +++ b/src/VirtualClient/VirtualClient.Contracts/VirtualClientRuntime.cs @@ -58,7 +58,7 @@ public static class VirtualClientRuntime public static string[] CommandLineArguments { get; internal set; } /// - /// Metadata provided to VC on the command line. + /// The current experiment ID for the application. /// public static IReadOnlyDictionary CommandLineMetadata { get; internal set; } @@ -67,11 +67,6 @@ public static class VirtualClientRuntime /// public static IReadOnlyDictionary CommandLineParameters { get; internal set; } - /// - /// The current experiment ID for the application. - /// - public static string ExperimentId { get; internal set; } - /// /// The current platform-specifics for the application. /// diff --git a/src/VirtualClient/VirtualClient.Controller.UnitTests/ExecuteSshCommandTests.cs b/src/VirtualClient/VirtualClient.Controller.UnitTests/ExecuteSshCommandTests.cs new file mode 100644 index 0000000000..6ca4e2ae45 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Controller.UnitTests/ExecuteSshCommandTests.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Controller +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.Runtime.InteropServices; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using Moq; + using NUnit.Framework; + using Renci.SshNet; + using VirtualClient.Common; + + [TestFixture] + [Category("Unit")] + internal class ExecuteSshCommandTests : MockFixture + { + private ConnectionInfo mockConnection; + private Mock mockSshClient; + + public void SetupTest(PlatformID platform, Architecture architecture = Architecture.X64) + { + this.Setup(platform, architecture); + + string mockCommand = "bash -c \"anycommand.sh -argument1 1234 -argument2 5678\""; + this.Parameters[nameof(ExecuteSshCommand.Command)] = mockCommand; + + this.mockConnection = new ConnectionInfo("192.168.1.15", "user01", new PasswordAuthenticationMethod("user01", "pw")); + this.mockSshClient = new Mock(); + + // Setup: + // The SSH client executes a command and returns a valid result. + this.mockSshClient.Setup(client => client.ConnectionInfo) + .Returns(this.mockConnection); + + this.mockSshClient.Setup(client => client.ExecuteCommandAsync(It.IsAny(), It.IsAny(), It.IsAny>())) + .ReturnsAsync(new ProcessDetails + { + Id = 1234, + CommandLine = mockCommand, + StandardOutput = $"Executed command" + }); + + this.Dependencies.AddSingleton>(new List { this.mockSshClient.Object }); + } + + [Test] + public void ExecuteSshCommandCannotBeRanAsAMonitor() + { + this.SetupTest(PlatformID.Unix); + + using (var component = new TestExecuteSshCommand(this)) + { + component.ComponentType = ComponentType.Monitor; + Assert.ThrowsAsync(() => component.ExecuteAsync(CancellationToken.None)); + } + } + + [Test] + [TestCase("bash -c \"anycommand.sh -argument1 11 -argument2 22\"")] + [TestCase("cmd -c \"anycommand.cmd -argument1 11 -argument2 22\"")] + public async Task ExecuteSshCommandExecutesTheExpectedCommand(string expectedCommand) + { + this.SetupTest(PlatformID.Unix); + this.Parameters[nameof(ExecuteSshCommand.Command)] = expectedCommand; + + using (var component = new TestExecuteSshCommand(this)) + { + this.mockSshClient.Setup(client => client.ExecuteCommandAsync(expectedCommand, It.IsAny(), It.IsAny>())) + .ReturnsAsync(new ProcessDetails + { + Id = 1234, + CommandLine = expectedCommand, + StandardOutput = $"Executed '{expectedCommand}'" + }); + + await component.ExecuteAsync(CancellationToken.None); + + this.mockSshClient.Verify(client => client.ExecuteCommandAsync(expectedCommand, It.IsAny(), It.IsAny>()), Times.Once); + } + } + + private class TestExecuteSshCommand : ExecuteSshCommand + { + public TestExecuteSshCommand(MockFixture mockFixture) + : base(mockFixture?.Dependencies, mockFixture?.Parameters) + { + } + } + } +} diff --git a/src/VirtualClient/VirtualClient.Controller.UnitTests/VirtualClient.Controller.UnitTests.csproj b/src/VirtualClient/VirtualClient.Controller.UnitTests/VirtualClient.Controller.UnitTests.csproj new file mode 100644 index 0000000000..c38b352f7a --- /dev/null +++ b/src/VirtualClient/VirtualClient.Controller.UnitTests/VirtualClient.Controller.UnitTests.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + false + false + false + VirtualClient.Controller.UnitTests + AsyncFixer02;SA1005;SA1120 + + + + + + + + + + + + + + + + + diff --git a/src/VirtualClient/VirtualClient.Controller.UnitTests/VirtualClientControllerComponentTests.cs b/src/VirtualClient/VirtualClient.Controller.UnitTests/VirtualClientControllerComponentTests.cs new file mode 100644 index 0000000000..56a98bdb61 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Controller.UnitTests/VirtualClientControllerComponentTests.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Controller +{ + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using NUnit.Framework; + using Renci.SshNet; + using Renci.SshNet.Common; + using VirtualClient; + using VirtualClient.Common.Telemetry; + + [TestFixture] + [Category("Unit")] + public class VirtualClientControllerComponentTests + { + private MockFixture mockFixture; + + [SetUp] + public void SetupTest() + { + this.mockFixture = new MockFixture(); + this.mockFixture.Setup(PlatformID.Unix); + } + + [Test] + [TestCase(10)] + [TestCase(100)] + [TestCase(500)] + public async Task VirtualClientControllerComponentSupportsConcurrentSshClientOperations(int concurrentExecutions) + { + List targetAgents = new List(); + for (int i = 0; i < concurrentExecutions; i++) + { + targetAgents.Add(new InMemorySshClient(new PasswordConnectionInfo($"10.1.2.{i + 1}", "anyuser", "anypass"))); + } + + this.mockFixture.Dependencies.AddSingleton>(targetAgents); + + using (var component = new TestVirtualClientControllerComponent(this.mockFixture)) + { + int actualConcurrentExecutions = 0; + component.OnExecute = (sshClient) => + { + Interlocked.Increment(ref actualConcurrentExecutions); + Task.Delay(10).GetAwaiter().GetResult(); + }; + + await Task.WhenAny( + // Execute the concurrent operations. + Task.Run(async () => await component.ExecuteAsync(CancellationToken.None)), + + // Timeout at some point in the case of multi-threading implementation mistakes. + Task.Run(async () => await Task.Delay(20000))); + + Assert.AreEqual(concurrentExecutions, actualConcurrentExecutions); + } + } + + [Test] + [TestCase(10)] + [TestCase(100)] + public async Task VirtualClientControllerComponentSupportsConcurrentSshClientOperationsWithFailures(int concurrentExecutions) + { + List targetAgents = new List(); + for (int i = 0; i < concurrentExecutions; i++) + { + targetAgents.Add(new InMemorySshClient(new PasswordConnectionInfo($"10.1.2.{i + 1}", "anyuser", "anypass"))); + } + + this.mockFixture.Dependencies.AddSingleton>(targetAgents); + + using (var component = new TestVirtualClientControllerComponent(this.mockFixture)) + { + int actualConcurrentExecutions = 0; + component.OnExecute = (sshClient) => + { + Interlocked.Increment(ref actualConcurrentExecutions); + throw new WorkloadException(); + }; + + await Task.WhenAny( + // Execute the concurrent operations. + Task.Run(async () => await component.ExecuteAsync(CancellationToken.None)), + + // Timeout at some point in the case of multi-threading implementation mistakes. + Task.Run(async () => await Task.Delay(20000))); + + Assert.AreEqual(concurrentExecutions, actualConcurrentExecutions); + } + } + + private class TestVirtualClientControllerComponent : VirtualClientControllerComponent + { + public TestVirtualClientControllerComponent(MockFixture mockFixture) + : base(mockFixture?.Dependencies, mockFixture?.Parameters) + { + } + + public Action OnExecute { get; set; } + + protected override Task ExecuteAsync(ISshClientProxy targetAgent, EventContext telemetryContext, CancellationToken cancellationToken) + { + this.OnExecute?.Invoke(targetAgent); + return Task.CompletedTask; + } + } + } +} diff --git a/src/VirtualClient/VirtualClient.Controller/AssemblyInfo.cs b/src/VirtualClient/VirtualClient.Controller/AssemblyInfo.cs new file mode 100644 index 0000000000..68fe6cf19b --- /dev/null +++ b/src/VirtualClient/VirtualClient.Controller/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; +using VirtualClient.Contracts; + +[assembly: VirtualClientComponentAssembly] +[assembly: InternalsVisibleTo("VirtualClient.Controller.UnitTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100ff6dccd60f1b1d8a4c0cbb5481e9aee8d193b61c5402b0ffd28f015d22402539894d63eee174845d816fe36b1baba6c6724a19fd62b27d12576327a1d6ba29ed7e1a0021dbdf53c5367536f9f40190060569cee75378b8196c6001773a330e36ce0d50814338c0b064289749ccb58dc292d7440791a2760bde7dbfe894860bc8")] \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Controller/ExecuteSshCommand.cs b/src/VirtualClient/VirtualClient.Controller/ExecuteSshCommand.cs new file mode 100644 index 0000000000..105ac0c1f7 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Controller/ExecuteSshCommand.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Controller +{ + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + using VirtualClient.Common; + using VirtualClient.Common.Extensions; + using VirtualClient.Common.Telemetry; + using VirtualClient.Contracts; + using VirtualClient.Logging; + + /// + /// Component executes an SSH command on a target system. + /// + [SupportedPlatforms("linux-arm64,linux-x64,win-arm64,win-x64")] + public class ExecuteSshCommand : VirtualClientControllerComponent + { + /// + /// Initializes a new instance of the class. + /// + /// Provides required dependencies by the component. + /// Parameters provided to the component. + public ExecuteSshCommand(IServiceCollection dependencies, IDictionary parameters = null) + : base(dependencies, parameters) + { + } + + /// + /// Parameter defines the command(s) to execute. Multiple commands should be delimited using + /// the '&&' characters which works on both Windows and Unix systems (e.g. ./configure&&make). + /// + public string Command + { + get + { + return this.Parameters.GetValue(nameof(this.Command)); + } + } + + /// + protected override async Task ExecuteAsync(ISshClientProxy sshTarget, EventContext telemetryContext, CancellationToken cancellationToken) + { + telemetryContext.AddContext("command", SensitiveData.ObscureSecrets(this.Command)); + + try + { + await sshTarget.ConnectAsync(); + ProcessDetails result = await sshTarget.ExecuteCommandAsync(this.Command, cancellationToken); + + if (!cancellationToken.IsCancellationRequested) + { + sshTarget.StandardOutput?.WriteLine(); + sshTarget.StandardOutput?.WriteLine($"[Execute Command on Target]"); + sshTarget.StandardOutput?.WriteLine($"Command: {SensitiveData.ObscureSecrets(this.Command)}"); + + await this.LogProcessDetailsAsync(result, telemetryContext); + this.ThrowIfCommandFailed(result); + } + } + catch (OperationCanceledException) + { + // Expected when a cancellation is requested. + } + catch (Exception exc) + { + sshTarget.StandardError?.WriteLine(exc.Message); + throw; + } + finally + { + sshTarget.Disconnect(); + } + } + + /// + /// Validates the parameters and initial state of the component. + /// + protected override void Validate() + { + base.Validate(); + if (this.ComponentType == ComponentType.Monitor) + { + throw new NotSupportedException($"Invalid component usage. The '{nameof(ExecuteSshCommand)}' component cannot be used as a monitor."); + } + } + + private void ThrowIfCommandFailed(ProcessDetails result) + { + if (result.ExitCode != 0) + { + if (this.ComponentType == ComponentType.Action) + { + throw new WorkloadException( + $"SSH command execution failed (exit code = {result.ExitCode}, command = {SensitiveData.ObscureSecrets(result.CommandLine)}).", + ErrorReason.WorkloadFailed); + } + else if (this.ComponentType == ComponentType.Dependency) + { + throw new DependencyException( + $"SSH command execution failed (exit code = {result.ExitCode}, command = {SensitiveData.ObscureSecrets(result.CommandLine)}).", + ErrorReason.DependencyInstallationFailed); + } + } + } + } +} diff --git a/src/VirtualClient/VirtualClient.Controller/RemoteAgentExecutor.cs b/src/VirtualClient/VirtualClient.Controller/RemoteAgentExecutor.cs new file mode 100644 index 0000000000..ee453a89da --- /dev/null +++ b/src/VirtualClient/VirtualClient.Controller/RemoteAgentExecutor.cs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Controller +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Runtime.InteropServices; + using System.Text.RegularExpressions; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.CodeAnalysis.Options; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + using Renci.SshNet; + using VirtualClient.Common; + using VirtualClient.Common.Extensions; + using VirtualClient.Common.Telemetry; + using VirtualClient.Contracts; + using VirtualClient.Logging; + + /// + /// Component executes the Virtual Client command line on a target system. + /// + [SupportedPlatforms("linux-arm64,linux-x64,win-arm64,win-x64")] + public class RemoteAgentExecutor : VirtualClientControllerComponent + { + /// + /// Initializes a new instance of the class. + /// + /// Provides required dependencies by the component. + /// Parameters provided to the component. + public RemoteAgentExecutor(IServiceCollection dependencies, IDictionary parameters = null) + : base(dependencies, parameters) + { + // Prevent any of the parameters from being evaluated. They MUST be shipped to the target system + // as-is and resolved on that target system. + this.ParametersEvaluated = true; + } + + /// + /// The command to execute on the remote system. When not defined, the full command + /// line pass to Virtual Client will be executed. + /// + public string Command + { + get + { + this.Parameters.TryGetValue(nameof(this.Command), out IConvertible command); + return command?.ToString(); + } + } + + /// + /// True/false whether the executor should copy logs from the target agent system + /// back to the controller system. Default = true. + /// + public bool CopyLogs + { + get + { + return this.Parameters.GetValue(nameof(this.CopyLogs), true); + } + } + + /// + /// Copies logs from the target agent/system to the current system through an SSH connection. + /// + /// The target agent from which to copy the logs. + /// Provides context information to include in telemetry output. + /// A token that can be used to cancel the operation. + protected async Task CopyLogsAsync(ISshClientProxy sshTarget, EventContext telemetryContext, CancellationToken cancellationToken) + { + try + { + await sshTarget.ConnectAsync(cancellationToken); + + Tuple platformArchitecture = await sshTarget.GetTargetPlatformArchitectureAsync(cancellationToken); + PlatformID targetPlatform = platformArchitecture.Item1; + Architecture targetArchitecture = platformArchitecture.Item2; + PlatformSpecifics targetPlatformSpecifics = new PlatformSpecifics(targetPlatform, targetArchitecture); + + // The user CAN set an alternate packages path on the target system using the 'VC_LOGS_DIR' + // environment variable. + string targetHost = sshTarget.ConnectionInfo.Host; + string targetLogsPath = VirtualClientControllerComponent.GetDefaultRemoteLogsPath(targetPlatformSpecifics); + string localLogsPath = this.PlatformSpecifics.GetLogsPath(targetHost.ToLowerInvariant(), this.ExperimentId.ToLowerInvariant()); + + telemetryContext.AddContext("copyLogsFrom", targetLogsPath); + telemetryContext.AddContext("copyLogsTo", localLogsPath); + + if (await sshTarget.ExistsAsync(targetLogsPath)) + { + sshTarget.StandardOutput?.WriteLine(); + sshTarget.StandardOutput?.WriteLine($"[Copy Logs to Controller]"); + await this.CopyDirectoryFromAsync(sshTarget, targetLogsPath, localLogsPath, telemetryContext, cancellationToken); + await sshTarget.DeleteDirectoryAsync(targetLogsPath); + } + } + finally + { + sshTarget.Disconnect(); + } + } + + /// + /// Executes the command on the target system through an SSH connection. + /// + /// The target agent on which to execute. + /// Provides context information to include in telemetry output. + /// A token that can be used to cancel the operation. + protected override async Task ExecuteAsync(ISshClientProxy sshTarget, EventContext telemetryContext, CancellationToken cancellationToken) + { + try + { + await sshTarget.ConnectAsync(cancellationToken); + await this.ExecuteAgentAsync(sshTarget, telemetryContext, cancellationToken); + + if (this.CopyLogs) + { + await this.CopyLogsAsync(sshTarget, telemetryContext, cancellationToken); + } + } + catch (OperationCanceledException) + { + // Expected when a cancellation is requested. + } + catch (Exception exc) + { + sshTarget.StandardError?.WriteLine(exc.Message); + throw; + } + finally + { + sshTarget.Disconnect(); + } + } + + /// + /// Executes the agent command line on the target system. + /// + /// The SSH client to interface with the remote system. + /// Provides context information to include in telemetry output. + /// A token that can be used to cancel the operation. + protected async Task ExecuteAgentAsync(ISshClientProxy sshTarget, EventContext telemetryContext, CancellationToken cancellationToken) + { + Tuple platformArchitecture = await sshTarget.GetTargetPlatformArchitectureAsync(cancellationToken); + PlatformID targetPlatform = platformArchitecture.Item1; + Architecture targetArchitecture = platformArchitecture.Item2; + PlatformSpecifics targetPlatformSpecifics = new PlatformSpecifics(targetPlatform, targetArchitecture); + + if (this.SupportedPlatforms?.Any() == true && !this.SupportedPlatforms.Any(platform => string.Equals(platform, targetPlatformSpecifics.PlatformArchitectureName))) + { + return; + } + + // The default packages directory for controller scenarios will be a peer directory + // of the agent folder. + // + // e.g. + // C:\Users\Any\AgentPackage\linux-arm64 + // C:\Users\Any\AgentPackage\linux-x64 + // C:\Users\Any\AgentPackage\win-arm64 + // C:\Users\Any\AgentPackage\win-x64 + // C:\Users\Any\AgentPackage\packages + string sourcePackagesPath = this.GetPackagePath(); + + // We support different packages folders which can be defined on the command line + // (e.g. packages, packages-installers). + string sourcePackageName = Path.GetFileNameWithoutExtension(sourcePackagesPath); + + string agentName = RemoteAgentExecutor.AgentName; + if (targetPlatform == PlatformID.Unix) + { + agentName = $"./{agentName}"; + } + + string agentInstallationPath = VirtualClientControllerComponent.GetDefaultRemoteAgentInstallationPath(targetPlatformSpecifics); + string targetCommand = $"{agentName} {this.Command}"; + targetCommand = this.AddDefaultCommandLineOptions(targetPlatformSpecifics, targetCommand, sourcePackageName); + + sshTarget.StandardOutput?.WriteLine(); + sshTarget.StandardOutput?.WriteLine($"[Execute on Target]"); + sshTarget.StandardOutput?.WriteLine($"Command: {SensitiveData.ObscureSecrets(this.Command)}"); + + telemetryContext.AddContext("agentInstallationPath", agentInstallationPath); + telemetryContext.AddContext("agentTargetCommand", SensitiveData.ObscureSecrets(targetCommand)); + + string fullTargetCommand = $"cd \"{agentInstallationPath}\"&&{targetCommand}"; + + // Execute the VC/agent command on the target system. In practice, we are simply passing in the command line + // provided on the controller to the agent. + ProcessDetails result = await sshTarget.ExecuteCommandAsync(fullTargetCommand, cancellationToken); + + if (!cancellationToken.IsCancellationRequested) + { + await this.LogProcessDetailsAsync(result, telemetryContext); + this.ThrowIfCommandFailed(result); + } + } + + /// + /// Validates the parameters and initial state of the component. + /// + protected override void Validate() + { + base.Validate(); + if (this.ComponentType == ComponentType.Monitor) + { + throw new NotSupportedException($"Invalid component usage. The '{nameof(RemoteAgentExecutor)}' component cannot be used as a monitor."); + } + } + } +} diff --git a/src/VirtualClient/VirtualClient.Controller/RemoteAgentInstallation.cs b/src/VirtualClient/VirtualClient.Controller/RemoteAgentInstallation.cs new file mode 100644 index 0000000000..d1a1be05f0 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Controller/RemoteAgentInstallation.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Controller +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.IO.Abstractions; + using System.Linq; + using System.Runtime.InteropServices; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + using VirtualClient.Common; + using VirtualClient.Common.Telemetry; + using VirtualClient.Contracts; + using VirtualClient.Logging; + using PlatformArchitecture = VirtualClient.Contracts.PlatformSpecifics; + + /// + /// Component executes an SSH command on a target system. + /// + [SupportedPlatforms("linux-arm64,linux-x64,win-arm64,win-x64")] + public class RemoteAgentInstallation : RemotePackagesInstallation + { + /// + /// Initializes a new instance of the class. + /// + /// Provides required dependencies by the component. + /// Parameters provided to the component. + public RemoteAgentInstallation(IServiceCollection dependencies, IDictionary parameters = null) + : base(dependencies, parameters) + { + } + + /// + /// Executes the command on the target system through an SSH connection. + /// + /// The target agent on which to execute. + /// Provides context information to include in telemetry output. + /// A token that can be used to cancel the operation. + protected override async Task ExecuteAsync(ISshClientProxy sshTarget, EventContext telemetryContext, CancellationToken cancellationToken) + { + try + { + await sshTarget.ConnectAsync(cancellationToken); + + if (!cancellationToken.IsCancellationRequested) + { + Tuple platformArchitecture = await sshTarget.GetTargetPlatformArchitectureAsync(cancellationToken); + PlatformID targetPlatform = platformArchitecture.Item1; + Architecture targetArchitecture = platformArchitecture.Item2; + PlatformArchitecture targetPlatformSpecifics = new PlatformArchitecture(targetPlatform, targetArchitecture); + + telemetryContext.AddContext("agentTargetPlatformArchitecture", targetPlatformSpecifics); + + if (!this.TryGetAgentPackagePath(targetPlatformSpecifics, out string agentPackagePath)) + { + throw new DependencyException( + $"Agent package not found. A package of this agent for platform/architecture '{targetPlatformSpecifics.PlatformArchitectureName}' was not found " + + $"on the current system. The target system requires an installation of the agent for this platform/architecture in order " + + $"to execute remote workflows. Ensure that the agent installation on the current system is a complete package containing all required " + + $"platform/architecture folders (e.g. {{agent_package}}/content/linux-arm64, linux-x64, win-arm64, win-x64).", + ErrorReason.DependencyNotFound); + } + + if (!cancellationToken.IsCancellationRequested) + { + string targetInstallationPath = VirtualClientControllerComponent.GetDefaultRemoteAgentInstallationPath(targetPlatformSpecifics); + + telemetryContext.AddContext("agentPackagePath", agentPackagePath); + telemetryContext.AddContext("agentTargetInstallationPath", targetInstallationPath); + + // The agent is copied fresh each time. Any directories or files on the target system + // will be deleted before copying. + + sshTarget.StandardOutput?.WriteLine(); + sshTarget.StandardOutput?.WriteLine($"[Copy Agent to Target]"); + await this.CopyDirectoryToAsync(sshTarget, agentPackagePath, targetInstallationPath, telemetryContext, cancellationToken, force: true); + + // e.g. + // chmod -R 2777 {target_path} + await this.SetFilePermissionsAsync(sshTarget, targetInstallationPath, targetPlatform, cancellationToken); + } + } + } + finally + { + sshTarget.Disconnect(); + } + } + + /// + /// Returns true/false whether the agent package for the target platform architecture is found. + /// + /// The platform/architecture for the target/remote system (e.g. linux-arm64, win-x64). + /// The path to the folder that contains the build of the agent to install on the target/remote system. + /// True if the correct agent for the target platform/architecture is found. + protected bool TryGetAgentPackagePath(PlatformArchitecture targetPlatformArchitecture, out string agentPackagePath) + { + agentPackagePath = null; + IDirectoryInfo currentDirectory = this.FileSystem.DirectoryInfo.New(this.PlatformSpecifics.CurrentDirectory).Parent; + + while (currentDirectory != null) + { + string matchingDirectory = this.FileSystem.Directory.EnumerateDirectories( + currentDirectory.FullName, + targetPlatformArchitecture.PlatformArchitectureName, + SearchOption.TopDirectoryOnly) + ?.FirstOrDefault(); + + if (!string.IsNullOrWhiteSpace(matchingDirectory)) + { + agentPackagePath = matchingDirectory; + break; + } + + currentDirectory = currentDirectory.Parent; + } + + return agentPackagePath != null; + } + } +} diff --git a/src/VirtualClient/VirtualClient.Controller/RemotePackagesInstallation.cs b/src/VirtualClient/VirtualClient.Controller/RemotePackagesInstallation.cs new file mode 100644 index 0000000000..83a50dc529 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Controller/RemotePackagesInstallation.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Controller +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Runtime.InteropServices; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + using VirtualClient.Common; + using VirtualClient.Common.Telemetry; + using VirtualClient.Contracts; + using VirtualClient.Logging; + + /// + /// Component installs packages from the local system to the remote system in the + /// remote agent 'packages' directory. + /// + [SupportedPlatforms("linux-arm64,linux-x64,win-arm64,win-x64")] + public class RemotePackagesInstallation : VirtualClientControllerComponent + { + /// + /// Initializes a new instance of the class. + /// + /// Provides required dependencies by the component. + /// Parameters provided to the component. + public RemotePackagesInstallation(IServiceCollection dependencies, IDictionary parameters = null) + : base(dependencies, parameters) + { + } + + /// + /// Executes the command on the target system through an SSH connection. + /// + /// The target agent on which to execute. + /// Provides context information to include in telemetry output. + /// A token that can be used to cancel the operation. + protected override async Task ExecuteAsync(ISshClientProxy sshTarget, EventContext telemetryContext, CancellationToken cancellationToken) + { + try + { + await sshTarget.ConnectAsync(cancellationToken); + + if (!cancellationToken.IsCancellationRequested) + { + Tuple platformArchitecture = await sshTarget.GetTargetPlatformArchitectureAsync(cancellationToken); + PlatformID targetPlatform = platformArchitecture.Item1; + Architecture targetArchitecture = platformArchitecture.Item2; + PlatformSpecifics targetPlatformSpecifics = new PlatformSpecifics(targetPlatform, targetArchitecture); + + await this.InstallPackagesAsync(sshTarget, targetPlatformSpecifics, telemetryContext, cancellationToken); + } + } + catch (OperationCanceledException) + { + // Expected when a cancellation is requested. + } + catch (Exception exc) + { + sshTarget.StandardError?.WriteLine(exc.Message); + throw; + } + finally + { + sshTarget.Disconnect(); + } + } + + /// + /// Installs packages on the target system. + /// + /// The SSH client to interface with the remote system. + /// The platform and CPU architecture for the target system (e.g. Windows, Linux, X64, ARM64). + /// Provides context information to include in telemetry output. + /// A token that can be used to cancel the operation. + protected async Task InstallPackagesAsync(ISshClientProxy sshTarget, PlatformSpecifics targetPlatformArchitecture, EventContext telemetryContext, CancellationToken cancellationToken) + { + telemetryContext.AddContext("agentTargetPlatformArchitecture", targetPlatformArchitecture.PlatformArchitectureName); + + if (!cancellationToken.IsCancellationRequested) + { + // The default packages directory for controller scenarios will be a peer directory + // of the agent folder. + // + // e.g. + // C:\Users\Any\AgentPackage\linux-arm64 + // C:\Users\Any\AgentPackage\linux-x64 + // C:\Users\Any\AgentPackage\win-arm64 + // C:\Users\Any\AgentPackage\win-x64 + // C:\Users\Any\AgentPackage\packages + string sourcePackagesPath = this.GetPackagePath(); + + // We support different packages folders which can be defined on the command line + // (e.g. packages, packages-installers). + string sourcePackageName = Path.GetFileNameWithoutExtension(sourcePackagesPath); + string targetPackagesInstallationPath = VirtualClientControllerComponent.GetDefaultRemotePackagesInstallationPath(targetPlatformArchitecture, sourcePackageName); + + telemetryContext.AddContext("sourcePackagesPath", sourcePackagesPath); + telemetryContext.AddContext("targetPackagesInstallationPath", targetPackagesInstallationPath); + + // The packages are copied fresh each time. Any directories or files on the target system + // will be deleted before copying. + sshTarget.StandardOutput?.WriteLine(); + sshTarget.StandardOutput?.WriteLine($"[Copy Packages to Target]"); + await this.CopyDirectoryToAsync(sshTarget, sourcePackagesPath, targetPackagesInstallationPath, telemetryContext, cancellationToken, force: true); + + // e.g. + // chmod -R 2777 {target_path} + await this.SetFilePermissionsAsync(sshTarget, targetPackagesInstallationPath, targetPlatformArchitecture.Platform, cancellationToken); + } + } + + /// + /// Validates the parameters and initial state of the component. + /// + protected override void Validate() + { + base.Validate(); + if (this.ComponentType == ComponentType.Monitor) + { + throw new NotSupportedException($"Invalid component usage. The '{this.TypeName}' component cannot be used as a monitor."); + } + } + } +} diff --git a/src/VirtualClient/VirtualClient.Controller/VirtualClient.Controller.csproj b/src/VirtualClient/VirtualClient.Controller/VirtualClient.Controller.csproj new file mode 100644 index 0000000000..7c317e4b45 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Controller/VirtualClient.Controller.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + VirtualClient.Controller + SA1508;CA1416;CS4014;CA2213;IL2026 + + + + + + + + + + + + + $([System.String]::new('%(ScriptFiles.RelativeDir)').ToLower().Split('\\')[0]) + + + + + + + + + diff --git a/src/VirtualClient/VirtualClient.Controller/VirtualClientControllerComponent.cs b/src/VirtualClient/VirtualClient.Controller/VirtualClientControllerComponent.cs new file mode 100644 index 0000000000..535d5fb487 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Controller/VirtualClientControllerComponent.cs @@ -0,0 +1,503 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Controller +{ + using System; + using System.Collections.Generic; + 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; + using Microsoft.Extensions.Logging; + using VirtualClient.Common; + using VirtualClient.Common.Extensions; + using VirtualClient.Common.Telemetry; + using VirtualClient.Contracts; + using VirtualClient.Logging; + + /// + /// Represents a component that performs remote execution on a target system through + /// an SSH session. + /// + public abstract class VirtualClientControllerComponent : VirtualClientComponent + { + internal static readonly string AgentName = Path.GetFileNameWithoutExtension(VirtualClientRuntime.ExecutableName); + private static readonly SemaphoreSlim AgentOutputLock = new SemaphoreSlim(1, 1); + + /// + /// Initializes a new instance of the class. + /// + /// Provides required dependencies by the component. + /// Parameters provided to the component. + protected VirtualClientControllerComponent(IServiceCollection dependencies, IDictionary parameters = null) + : base(dependencies, parameters) + { + this.FileSystem = dependencies.GetService(); + } + + /// + /// Provides an interface to the local file system. + /// + protected IFileSystem FileSystem { get; } + + /// + /// Returns the default installation path for the remote agent. + ///
+ /// + /// + /// Default on Linux = $HOME/{AgentFolder}/{PlatformArchitecture}
+ /// (e.g. /home/user/Agent/linux-arm64). + ///
+ /// + /// Default on Windows = %USERPROFILE%\{AgentFolder}\{PlatformArchitecture}
+ /// (e.g. C:\Users\Agent\win-x64). + ///
+ ///
+ ///
+ /// Provides the platform and CPU architecture for the target/remote system (e.g. Linux, Windows, X64, ARM64). + /// A path on the target system to which the remote agent exists or can be installed. + protected static string GetDefaultRemoteAgentInstallationPath(PlatformSpecifics targetPlatformSpecifics) + { + string defaultPath = null; + string agentName = VirtualClientControllerComponent.AgentName; + + if (targetPlatformSpecifics.Platform == PlatformID.Unix) + { + defaultPath = $"$HOME/{agentName}/{targetPlatformSpecifics.PlatformArchitectureName}"; + } + else if (targetPlatformSpecifics.Platform == PlatformID.Win32NT) + { + defaultPath = $"%USERPROFILE%\\{agentName}\\{targetPlatformSpecifics.PlatformArchitectureName}"; + } + + if (string.IsNullOrWhiteSpace(defaultPath)) + { + throw new NotSupportedException( + $"Unsupported platform '{targetPlatformSpecifics.PlatformArchitectureName}'. Supported platforms are Unix and Windows."); + } + + return defaultPath; + } + + /// + /// Returns the default path for logs/log files on the remote system. + ///
+ /// + /// + /// Default on Linux = $HOME/{AgentFolder}/{PlatformArchitecture}/logs
+ /// (e.g. /home/user/Agent/linux-arm64/logs). + ///
+ /// + /// Default on Windows = %USERPROFILE%\{AgentFolder}\{PlatformArchitecture}\logs
+ /// (e.g. C:\Users\Agent\win-arm64\logs). + ///
+ ///
+ ///
+ /// Provides the platform and CPU architecture for the target/remote system (e.g. Linux, Windows, X64, ARM64). + /// A path on the target system to which the remote packages exists or can be installed. + protected static string GetDefaultRemoteLogsPath(PlatformSpecifics targetPlatformSpecifics) + { + string defaultPath = null; + string agentName = VirtualClientControllerComponent.AgentName; + + if (targetPlatformSpecifics.Platform == PlatformID.Unix) + { + defaultPath = $"$HOME/{agentName}/logs"; + } + else if (targetPlatformSpecifics.Platform == PlatformID.Win32NT) + { + defaultPath = $"%USERPROFILE%\\{agentName}\\logs"; + } + + if (string.IsNullOrWhiteSpace(defaultPath)) + { + throw new NotSupportedException( + $"Unsupported platform '{targetPlatformSpecifics.PlatformArchitectureName}'. Supported platforms are Unix and Windows."); + } + + return defaultPath; + } + + /// + /// Returns the default installation path for packages on the remote system. + ///
+ /// + /// + /// Default on Linux = $HOME/{AgentFolder}/packages
+ /// (e.g. /home/user/Agent/packages). + ///
+ /// + /// Default on Windows = %USERPROFILE%\{AgentFolder}\packages
+ /// (e.g. C:\Users\Agent\packages). + ///
+ ///
+ ///
+ /// Provides the platform and CPU architecture for the target/remote system (e.g. Linux, Windows, X64, ARM64). + /// The folder name for the packages allowing for different packages. + /// A path on the target system to which the remote packages exists or can be installed. + protected static string GetDefaultRemotePackagesInstallationPath(PlatformSpecifics targetPlatformSpecifics, string packagesFolder = "packages") + { + string defaultPath = null; + string agentName = VirtualClientControllerComponent.AgentName; + + if (targetPlatformSpecifics.Platform == PlatformID.Unix) + { + defaultPath = $"$HOME/{agentName}/{packagesFolder}"; + } + else if (targetPlatformSpecifics.Platform == PlatformID.Win32NT) + { + defaultPath = $"%USERPROFILE%\\{agentName}\\{packagesFolder}"; + } + + if (string.IsNullOrWhiteSpace(defaultPath)) + { + throw new NotSupportedException( + $"Unsupported platform '{targetPlatformSpecifics.PlatformArchitectureName}'. Supported platforms are Unix and Windows."); + } + + return defaultPath; + } + + /// + /// Adds default command line options to the command to provide to a target/remote system. + /// + protected string AddDefaultCommandLineOptions(PlatformSpecifics targetPlatformSpecifics, string command, string packagesFolder) + { + // TODO: + // This is NOT going to fly for long. This is a temporary workaround to the fact that the + // OptionFactory is defined in the VirtualClient.Main project and this is where the option names + // are defined. Duplicating these here is be easy to break. + string effectiveCommand = command; + Regex experimentIdExpression = new Regex("--e=|--experiment=|--experiment-id=|--experimentId=|--experimentid="); + Regex packageDirectoryExpression = new Regex("--pdir=|--package-dir="); + Regex logDirectoryExpression = new Regex("--ldir=|--log-dir="); + Regex logToFileExpression = new Regex("--ltf|--log-to-file"); + + if (!experimentIdExpression.IsMatch(effectiveCommand)) + { + effectiveCommand += $" --experiment-id={this.ExperimentId}"; + } + + if (!packageDirectoryExpression.IsMatch(effectiveCommand)) + { + effectiveCommand += $" --package-dir={VirtualClientControllerComponent.GetDefaultRemotePackagesInstallationPath(targetPlatformSpecifics, packagesFolder)}"; + } + + if (!logDirectoryExpression.IsMatch(command)) + { + effectiveCommand += $" --log-dir={VirtualClientControllerComponent.GetDefaultRemoteLogsPath(targetPlatformSpecifics)}"; + } + + if (!logToFileExpression.IsMatch(effectiveCommand)) + { + effectiveCommand += $" --log-to-file"; + } + + return effectiveCommand; + } + + /// + /// Copies a directory on the target system to the local system via the SSH client session. + /// + /// The SSH client to handle the copy operation. + /// The target directory on the remote system from which the directory will be copied. + /// The directory on the local system to copy into. + /// Provides context information to include with telemetry data. + /// A token that can be used to cancel the operation. + protected Task CopyDirectoryFromAsync(ISshClientProxy sshTarget, string remoteDirectoryPath, string localDirectoryPath, EventContext telemetryContext, CancellationToken cancellationToken) + { + sshTarget.ThrowIfNull(nameof(sshTarget)); + localDirectoryPath.ThrowIfNullOrWhiteSpace(nameof(localDirectoryPath)); + remoteDirectoryPath.ThrowIfNullOrWhiteSpace(nameof(remoteDirectoryPath)); + telemetryContext.ThrowIfNull(nameof(telemetryContext)); + + EventContext relatedContext = telemetryContext.Clone(); + return this.Logger.LogMessageAsync($"{this.TypeName}.CopyDirectoryFrom", LogLevel.Trace, relatedContext, async () => + { + DateTime copyStartTime = DateTime.UtcNow; + relatedContext.AddContext("copyStartTime", copyStartTime); + + try + { + sshTarget.StandardOutput?.WriteLine($"Copy From: {remoteDirectoryPath}"); + sshTarget.StandardOutput?.WriteLine($"Copy To: {localDirectoryPath}"); + + IDirectoryInfo localDirectory = this.FileSystem.DirectoryInfo.New(localDirectoryPath); + if (!localDirectory.Exists) + { + localDirectory.Create(); + } + + await sshTarget.CopyFromAsync(remoteDirectoryPath, localDirectory, cancellationToken); + } + finally + { + DateTime copyEndTime = DateTime.UtcNow; + TimeSpan copyTimeElapsed = copyEndTime - copyStartTime; + sshTarget.StandardOutput?.WriteLine($"Elapsed Time = {copyTimeElapsed}"); + + relatedContext.AddContext("copyEndTime", copyStartTime); + relatedContext.AddContext("totalCopyTime", copyTimeElapsed.ToString()); + } + }); + } + + /// + /// Copies a source directory on the local system to the target system via the SSH client session. + /// + /// The SSH client to handle the copy operation. + /// The source directory on the local system. + /// The target directory on the remote system to which the directory will be copied. + /// Provides context information to include with telemetry data. + /// A token that can be used to cancel the operation. + /// A buffer to write standard output. + /// True to force the target directory to be recreated from scratch. The directory will be deleted if it exists. + protected Task CopyDirectoryToAsync(ISshClientProxy sshTarget, string sourceDirectoryPath, string targetDirectoryPath, EventContext telemetryContext, CancellationToken cancellationToken, TextWriter outputBuffer = null, bool force = false) + { + sshTarget.ThrowIfNull(nameof(sshTarget)); + sourceDirectoryPath.ThrowIfNullOrWhiteSpace(nameof(sourceDirectoryPath)); + targetDirectoryPath.ThrowIfNullOrWhiteSpace(nameof(targetDirectoryPath)); + telemetryContext.ThrowIfNull(nameof(telemetryContext)); + + EventContext relatedContext = telemetryContext.Clone(); + return this.Logger.LogMessageAsync($"{this.TypeName}.CopyDirectoryTo", LogLevel.Trace, relatedContext, async () => + { + IDirectoryInfo source = this.FileSystem.DirectoryInfo.New(sourceDirectoryPath); + IEnumerable allFiles = source.EnumerateFiles("*.*", SearchOption.AllDirectories); + + if (allFiles?.Any() != true) + { + throw new DependencyException( + $"Invalid source directory. The source package/directory at the path '{sourceDirectoryPath}' does not contain any files.", + ErrorReason.DependencyNotFound); + } + + double totalBytesToCopy = allFiles.Sum(file => file.Length); + double totalBytesCopied = 0; + // double percentageCopied = 0; + + relatedContext.AddContext("totalFilesToCopy", allFiles?.Count()); + relatedContext.AddContext("totalBytesToCopy", totalBytesToCopy); + + // e.g. 10, 20, 30... + List percentages = Enumerable.Range(1, 10).Select(p => p * 10.0).ToList(); + + ////sshClient.CopyingTo += (sender, args) => + ////{ + //// // Note: + //// // The Uploading event sends notices as a given file is being uploaded. If the file + //// // is large enough, the event is fired multiple times as the file is being copied + //// // in parts/buffers. We can determine when a file is fully copied/uploaded when + //// // the ScpUploadEventArgs.Uploaded (bytes) equals the ScpUploadEventArgs.Size (bytes). + //// double percentageBytesCopied = ((totalBytesCopied + args.Uploaded) / totalBytesToCopy) * 100; + //// percentageCopied = Math.Floor(percentageBytesCopied); + + //// if (percentageCopied > 0 && percentages.Contains(percentageCopied)) + //// { + //// percentages.RemoveAt(0); + //// ConsoleLogger.Default.LogMessage($"Copied: {percentageCopied}%", relatedContext); + //// } + + //// if (args.Uploaded >= args.Size) + //// { + //// totalBytesCopied += args.Size; + //// } + ////}; + + DateTime copyStartTime = DateTime.UtcNow; + relatedContext.AddContext("copyStartTime", copyStartTime); + + try + { + sshTarget.StandardOutput?.WriteLine($"Copy From: {sourceDirectoryPath}"); + sshTarget.StandardOutput?.WriteLine($"Copy To: {targetDirectoryPath}"); + + await sshTarget.CreateDirectoryAsync(targetDirectoryPath, force: force); + await sshTarget.CopyToAsync(source, targetDirectoryPath); + } + finally + { + DateTime copyEndTime = DateTime.UtcNow; + TimeSpan copyTimeElapsed = copyEndTime - copyStartTime; + sshTarget.StandardOutput?.WriteLine($"Elapsed Time: {copyTimeElapsed}", relatedContext); + + relatedContext.AddContext("copyEndTime", copyStartTime); + relatedContext.AddContext("totalCopyTime", copyTimeElapsed.ToString()); + relatedContext.AddContext("totalBytesCopied", totalBytesCopied); + } + }); + } + + /// + /// Executes the operations on the target agent system through SSH connections. + /// + protected override Task ExecuteAsync(EventContext telemetryContext, CancellationToken cancellationToken) + { + if (!this.TryGetTargetAgentClients(out IEnumerable sshClients)) + { + throw new WorkloadException( + "Target agents not defined. One or more target agent SSH connections must be defined on the command line.", + ErrorReason.WorkloadDependencyMissing); + } + + foreach (ISshClientProxy sshClient in sshClients) + { + ConsoleLogger.Default.LogMessage($"Target Agent: {sshClient.ConnectionInfo.Username}@{sshClient.ConnectionInfo.Host}", telemetryContext); + } + + Console.WriteLine(); + List agentExecutionTasks = new List(); + + foreach (ISshClientProxy sshClient in sshClients) + { + EventContext relatedContext = telemetryContext.Clone(); + relatedContext.AddContext("targetAgent", $"{sshClient.ConnectionInfo.Username}@{sshClient.ConnectionInfo.Host}"); + + agentExecutionTasks.Add(Task.Run(async () => + { + if (!cancellationToken.IsCancellationRequested) + { + await this.Logger.LogMessageAsync($"{this.TypeName}.ExecuteOnTarget", LogLevel.Trace, relatedContext, async () => + { + using (TextWriter standardOutput = new StringWriter()) + { + using (TextWriter standardError = new StringWriter()) + { + sshClient.StandardError = standardOutput; + sshClient.StandardOutput = standardError; + + try + { + await this.ExecuteAsync(sshClient, relatedContext, cancellationToken); + } + catch (OperationCanceledException) + { + // Expected when a cancellation is requested. + } + catch (Exception exc) + { + sshClient.StandardError?.WriteLine(); + sshClient.StandardError?.WriteLine(exc.Message); + throw; + } + finally + { + relatedContext.AddContext("standardOutput", sshClient.StandardOutput); + relatedContext.AddContext("standardError", sshClient.StandardError); + + if (!cancellationToken.IsCancellationRequested) + { + VirtualClientControllerComponent.ConsoleLogAgentResultsAsync(sshClient, cancellationToken); + } + } + } + } + }); + } + })); + } + + return Task.WhenAll(agentExecutionTasks); + } + + /// + /// Executes the operations on the target agent through an SSH connection. + /// + /// The target agent on which to execute the operations. + /// Provides context information to include with telemetry data. + /// A token that can be used to cancel the operation. + /// + protected abstract Task ExecuteAsync(ISshClientProxy sshTarget, EventContext telemetryContext, CancellationToken cancellationToken); + + /// + /// Sets the file (and folder) permissions on the target system via the SSH client session + /// (e.g. executable, read, write). + /// + /// The SSH client to handle the copy operation. + /// The source directory on the local system. + /// The target system platform (e.g. Linux, Windows). + /// A token that can be used to cancel the operation. + protected async Task SetFilePermissionsAsync(ISshClientProxy sshTarget, string targetPath, PlatformID targetPlatform, CancellationToken cancellationToken) + { + sshTarget.ThrowIfNull(nameof(sshTarget)); + targetPath.ThrowIfNullOrWhiteSpace(nameof(targetPath)); + + if (targetPlatform == PlatformID.Unix) + { + sshTarget.StandardOutput?.WriteLine($"Set Permissions: {targetPath}"); + await sshTarget.ExecuteCommandAsync($"chmod -R 2777 \"{targetPath}\""); + } + } + + /// + /// Throws an exception if the result indicates an error. + /// + protected void ThrowIfCommandFailed(ProcessDetails result, params int[] successCodes) + { + bool isErrored = result.ExitCode != 0; + if (successCodes?.Any() == true) + { + isErrored = !successCodes.Contains(result.ExitCode); + } + + if (isErrored) + { + StringBuilder errorMessage = new StringBuilder(); + errorMessage.AppendLine($"SSH command execution failed (exit code = {result.ExitCode}, command = {SensitiveData.ObscureSecrets(result.CommandLine)})."); + + if (!string.IsNullOrWhiteSpace(result.StandardError)) + { + errorMessage.AppendLine(); + errorMessage.AppendLine(result.StandardError); + } + + if (this.ComponentType == ComponentType.Action) + { + throw new WorkloadException(errorMessage.ToString(), ErrorReason.WorkloadFailed); + } + else if (this.ComponentType == ComponentType.Dependency) + { + throw new DependencyException(errorMessage.ToString(), ErrorReason.DependencyInstallationFailed); + } + } + } + + /// + /// Returns true if a target agent SSH client is defined (i.e. as supplied on the command line). + /// + /// Target agent SSH client as defined on the command line. + /// True if target agent SSH clients exist. False if not. + protected bool TryGetTargetAgentClients(out IEnumerable sshClients) + { + sshClients = null; + if (this.Dependencies.TryGetService>(out IEnumerable client)) + { + sshClients = client; + } + + return sshClients != null; + } + + private static async Task ConsoleLogAgentResultsAsync(ISshClientProxy sshClient, CancellationToken cancellationToken) + { + await VirtualClientControllerComponent.AgentOutputLock.WaitAsync(cancellationToken); + + try + { + Console.WriteLine($"Agent Results: {sshClient.ConnectionInfo.Username}@{sshClient.ConnectionInfo.Host}"); + Console.WriteLine($"------------------------------------------------------------"); + Console.WriteLine($"{sshClient.StandardOutput}{Environment.NewLine}{Environment.NewLine}{sshClient.StandardError}".Trim()); + Console.WriteLine(); + } + finally + { + VirtualClientControllerComponent.AgentOutputLock.Release(); + } + } + } +} diff --git a/src/VirtualClient/VirtualClient.Core.UnitTests/ProfileExecutorTests.cs b/src/VirtualClient/VirtualClient.Core.UnitTests/ProfileExecutorTests.cs index cd583e8387..78cb666cee 100644 --- a/src/VirtualClient/VirtualClient.Core.UnitTests/ProfileExecutorTests.cs +++ b/src/VirtualClient/VirtualClient.Core.UnitTests/ProfileExecutorTests.cs @@ -664,7 +664,7 @@ public async Task ProfileExecutorCorrelationIdentifiersAreCorrectForActionsExecu Assert.IsNotNull(eventsLogged); Assert.IsNotEmpty(eventsLogged); - var iterations = this.mockFixture.Logger.Where(log => log.Item2.Name == "ProfileExecutor.ExecuteActionsStart")?.Select(i => i.Item3 as EventContext); + var iterations = this.mockFixture.Logger.Where(log => log.Item2.Name == "ProfileExecutor.ExecuteActions")?.Select(i => i.Item3 as EventContext); Assert.IsNotNull(iterations); Assert.IsNotEmpty(iterations); Assert.IsTrue(iterations.Count() == 2); @@ -734,7 +734,7 @@ public async Task ProfileExecutorCorrelationIdentifiersAreCorrectForDependencies Assert.IsNotNull(eventsLogged); Assert.IsNotEmpty(eventsLogged); - var events = this.mockFixture.Logger.Where(log => log.Item2.Name == "ProfileExecutor.InstallDependenciesStart")?.Select(i => i.Item3 as EventContext); + var events = this.mockFixture.Logger.Where(log => log.Item2.Name == "ProfileExecutor.InstallDependencies")?.Select(i => i.Item3 as EventContext); Assert.IsNotNull(events); Assert.IsNotEmpty(events); Assert.IsTrue(events.Count() == 1); @@ -786,7 +786,7 @@ public async Task ProfileExecutorCorrelationIdentifiersAreCorrectForMonitorsExec Assert.IsNotNull(eventsLogged); Assert.IsNotEmpty(eventsLogged); - var events = this.mockFixture.Logger.Where(log => log.Item2.Name == "ProfileExecutor.ExecuteMonitorsStart")?.Select(i => i.Item3 as EventContext); + var events = this.mockFixture.Logger.Where(log => log.Item2.Name == "ProfileExecutor.ExecuteMonitors")?.Select(i => i.Item3 as EventContext); Assert.IsNotNull(events); Assert.IsNotEmpty(events); Assert.IsTrue(events.Count() == 1); diff --git a/src/VirtualClient/VirtualClient.Core.UnitTests/SshClientProxyTests.cs b/src/VirtualClient/VirtualClient.Core.UnitTests/SshClientProxyTests.cs index 3194b6b878..159a55f5a6 100644 --- a/src/VirtualClient/VirtualClient.Core.UnitTests/SshClientProxyTests.cs +++ b/src/VirtualClient/VirtualClient.Core.UnitTests/SshClientProxyTests.cs @@ -8,6 +8,7 @@ namespace VirtualClient using System.IO.Abstractions; using System.Threading; using System.Threading.Tasks; + using Microsoft.CodeAnalysis.Options; using NUnit.Framework; using Renci.SshNet; using VirtualClient.Common; @@ -25,6 +26,40 @@ public void SetupTest() this.fileSystem = new FileSystem(); } + [Test] + [TestCase("user@10.2.3.5;pass")] + [TestCase("user@machine_name;pass")] + [TestCase("user@2001:0db8:85a3:0000:0000:8a2e:0370:7334;pass")] + [TestCase("user@2001:db8:85a3:0:0:8a2e:370:7334;pass")] + [TestCase("user@2001:db8:85a3::8a2e:370:7334;pass")] + public void SshClientProxySupportsExpectedSshConnectionValues(string value) + { + string[] sections = value.Split("@"); + string[] sections2 = sections[1].Split(";"); + string expectedUser = sections[0]; + string expectedHost = sections2[0]; + string expectedPass = sections2[1]; + + Assert.IsTrue(SshClientProxy.TryGetSshTargetInformation(value, out string actualHost, out string actualUsername, out string actualPass)); + Assert.AreEqual(expectedUser, actualUsername); + Assert.AreEqual(expectedHost, actualHost); + Assert.AreEqual(expectedPass, actualPass); + } + + [Test] + [TestCase("user@10.2.3.5;pass_;wor;d", "user", "10.2.3.5", "pass_;wor;d")] + [TestCase("user@10.2.3.5;pass__w@rd", "user", "10.2.3.5", "pass__w@rd")] + [TestCase("user@machine@somewhere;pass", "user", "machine@somewhere", "pass")] + [TestCase("user@machine@somewhere;pass;_w@rd", "user", "machine@somewhere", "pass;_w@rd")] + [TestCase("user@2001:db8:85a3:0:0:8a2e:370:7334;pass;_w@rd", "user", "2001:db8:85a3:0:0:8a2e:370:7334", "pass;_w@rd")] + public void SshClientProxyHandlesSshConnectionValuesContainingDelimitersInTrickyLocations(string value, string expectedUser, string expectedHost, string expectedPass) + { + Assert.IsTrue(SshClientProxy.TryGetSshTargetInformation(value, out string actualHost, out string actualUser, out string actualPass)); + Assert.AreEqual(expectedUser, actualUser); + Assert.AreEqual(expectedHost, actualHost); + Assert.AreEqual(expectedPass, actualPass); + } + [Test] public void SshClientProxyConstructorsValidatesRequiredProperties() { @@ -213,6 +248,64 @@ public async Task SshClientProxyExecutesTheExpectedCommandThroughTheSshSession() } } + [Test] + public async Task SshClientProxySetsStandardOutputWhenAStreamIsDefined() + { + ConnectionInfo expectedInfo = new ConnectionInfo("192.168.1.15", "anyuser", new TestAuthenticationMethod("anyuser")); + using (var client = new TestSshClientProxy(expectedInfo)) + { + client.StandardOutput = new StringWriter(); + string expectedCommand = "python execute_this_or_that.py --log-dir /home/user/logs"; + + string expectedStandardOutput = Guid.NewGuid().ToString(); + client.OnExecuteCommandOnRemoteSystem = (sshCommand) => + { + return new ProcessDetails + { + Id = 1234, + CommandLine = expectedCommand, + ExitCode = 0, + StandardOutput = expectedStandardOutput, + ToolName = "SSH" + }; + }; + + ProcessDetails result = await client.ExecuteCommandAsync(expectedCommand, CancellationToken.None); + + Assert.IsNotNull(client.StandardOutput); + Assert.AreEqual(expectedStandardOutput, client.StandardOutput.ToString()); + } + } + + [Test] + public async Task SshClientProxySetsStandardErrorWhenAStreamIsDefined() + { + ConnectionInfo expectedInfo = new ConnectionInfo("192.168.1.15", "anyuser", new TestAuthenticationMethod("anyuser")); + using (var client = new TestSshClientProxy(expectedInfo)) + { + client.StandardError = new StringWriter(); + string expectedCommand = "python execute_this_or_that.py --log-dir /home/user/logs"; + + string expectedStandardError = Guid.NewGuid().ToString(); + client.OnExecuteCommandOnRemoteSystem = (sshCommand) => + { + return new ProcessDetails + { + Id = 1234, + CommandLine = expectedCommand, + ExitCode = 0, + StandardError = expectedStandardError, + ToolName = "SSH" + }; + }; + + ProcessDetails result = await client.ExecuteCommandAsync(expectedCommand, CancellationToken.None); + + Assert.IsNotNull(client.StandardError); + Assert.AreEqual(expectedStandardError, client.StandardError.ToString()); + } + } + internal class TestAuthenticationMethod : AuthenticationMethod { public TestAuthenticationMethod(string username) diff --git a/src/VirtualClient/VirtualClient.Core/Cleanup/CleanupExtensions.cs b/src/VirtualClient/VirtualClient.Core/Cleanup/CleanupExtensions.cs index f689efdcee..31e405cd54 100644 --- a/src/VirtualClient/VirtualClient.Core/Cleanup/CleanupExtensions.cs +++ b/src/VirtualClient/VirtualClient.Core/Cleanup/CleanupExtensions.cs @@ -10,15 +10,34 @@ namespace VirtualClient.Cleanup using System.Linq; using System.Threading; using System.Threading.Tasks; - using VirtualClient.Common.Contracts; - using VirtualClient.Common.Extensions; - using VirtualClient.Contracts; /// /// Extension methods that support runtime cleanup operations. /// public static class CleanupExtensions { + /// + /// Cleans the default "contentuploads" directory provided deleting any files and folders that are beyond the + /// defined retention period. + /// + /// The system management instance. + /// A token that can be used to cancel the operation. + /// A retention date to apply to files within the directory. Any files created within this retention date are preserved. + public static async Task CleanContentUploadsDirectoryAsync(this ISystemManagement systemManagement, CancellationToken cancellationToken, DateTime? retentionDate = null) + { + IFileSystem fileSystem = systemManagement.FileSystem; + string contentUploadsDirectory = systemManagement.PlatformSpecifics.ContentUploadsDirectory; + + if (fileSystem.Directory.Exists(contentUploadsDirectory)) + { + IEnumerable uploadRequestFiles = fileSystem.Directory.EnumerateFiles(contentUploadsDirectory, "*.*", SearchOption.AllDirectories); + await CleanupExtensions.DeleteFilesAsync(fileSystem, uploadRequestFiles, retentionDate, cancellationToken); + + IEnumerable contentUploadDirectories = fileSystem.Directory.EnumerateDirectories(contentUploadsDirectory, "*.*", SearchOption.AllDirectories); + await CleanupExtensions.DeleteDirectoriesAsync(fileSystem, contentUploadDirectories, retentionDate, cancellationToken); + } + } + /// /// Cleans the default "logs" directory provided deleting any files and folders that are beyond the /// defined retention period. diff --git a/src/VirtualClient/VirtualClient.Core/DependencyFactory.cs b/src/VirtualClient/VirtualClient.Core/DependencyFactory.cs index 818ac4e7bb..ccfef60f0f 100644 --- a/src/VirtualClient/VirtualClient.Core/DependencyFactory.cs +++ b/src/VirtualClient/VirtualClient.Core/DependencyFactory.cs @@ -597,7 +597,8 @@ public static ProxyTelemetryChannel CreateProxyTelemetryChannel(IProxyApiClient /// The ID of the larger experiment in operation. /// Provides features for platform-specific operations (e.g. Windows, Unix). /// The logger to use for capturing telemetry. - public static ISystemManagement CreateSystemManager(string agentId, string experimentId, PlatformSpecifics platformSpecifics, Microsoft.Extensions.Logging.ILogger logger = null) + /// Instructs the factory to construct dependencies for cross-process/isolated runs. + public static ISystemManagement CreateSystemManager(string agentId, string experimentId, PlatformSpecifics platformSpecifics, Microsoft.Extensions.Logging.ILogger logger = null, bool isolated = false) { agentId.ThrowIfNullOrWhiteSpace(nameof(agentId)); experimentId.ThrowIfNullOrWhiteSpace(nameof(experimentId)); @@ -609,6 +610,12 @@ public static ISystemManagement CreateSystemManager(string agentId, string exper IFileSystem fileSystem = new FileSystem(); IFirewallManager firewallManager = DependencyFactory.CreateFirewallManager(platform, processManager); IPackageManager packageManager = new PackageManager(platformSpecifics, fileSystem, logger); + + if (isolated) + { + packageManager = new IsolatedPackageManager(packageManager); + } + ISshClientFactory sshClientManager = new SshClientFactory(); IStateManager stateManager = new StateManager(fileSystem, platformSpecifics); diff --git a/src/VirtualClient/VirtualClient.Core/FileSystemExtensions.cs b/src/VirtualClient/VirtualClient.Core/FileSystemExtensions.cs index 2cc3e20483..70828e91a8 100644 --- a/src/VirtualClient/VirtualClient.Core/FileSystemExtensions.cs +++ b/src/VirtualClient/VirtualClient.Core/FileSystemExtensions.cs @@ -13,6 +13,7 @@ namespace VirtualClient using System.Threading; using System.Threading.Tasks; using Polly; + using Polly.Retry; using VirtualClient.Common.Extensions; /// @@ -41,6 +42,42 @@ public static void ThrowIfFileDoesNotExist(this IFile fileHandler, string file, } } + /// + /// Attempts to delete a file with transient issue retry handling. + /// + /// An interface to the filesystem to interact on a per file basis. + /// the fully qualified path to the file to delete. + /// The retry policy to apply to the deletion. + public static void Delete(this IFile fileHandler, string file, RetryPolicy retryPolicy = null) + { + fileHandler.ThrowIfNull(nameof(fileHandler)); + file.ThrowIfNullOrWhiteSpace(nameof(file)); + + // IOException is thrown when a process still has a descriptor open on file. This is retryable + // when a process is closing. + // https://docs.microsoft.com/en-us/dotnet/api/system.io.file.delete?view=net-5.0 + (retryPolicy ?? Policy.Handle().WaitAndRetry(10, (attempts) => TimeSpan.FromSeconds(Math.Pow(attempts, 2)))).Execute(() => + { + try + { + fileHandler.Delete(file); + } + catch (FileNotFoundException) + { + // This can happen in certain scenarios. The outcome is the same as the one + // expected...the file no longer exists! + } + catch (DirectoryNotFoundException) + { + // This can happen in certain scenarios. The outcome is the same as the one + // expected...the file no longer exists! + } + + return Task.CompletedTask; + + }); + } + /// /// Attempts to delete a file with transient issue retry handling. /// diff --git a/src/VirtualClient/VirtualClient.Core/ISshClientProxy.cs b/src/VirtualClient/VirtualClient.Core/ISshClientProxy.cs index 87cff63d23..6a9f2d17c6 100644 --- a/src/VirtualClient/VirtualClient.Core/ISshClientProxy.cs +++ b/src/VirtualClient/VirtualClient.Core/ISshClientProxy.cs @@ -6,7 +6,6 @@ namespace VirtualClient using System; using System.IO; using System.IO.Abstractions; - using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Renci.SshNet; @@ -35,6 +34,18 @@ public interface ISshClientProxy : IDisposable /// ConnectionInfo ConnectionInfo { get; } + /// + /// Allows the capture of standard error from the execution of the commands through + /// the SSH client. + /// + TextWriter StandardError { get; set; } + + /// + /// Allows the capture of standard output from the execution of the commands through + /// the SSH client. + /// + TextWriter StandardOutput { get; set; } + /// /// Establishes a connection to target host. /// diff --git a/src/VirtualClient/VirtualClient.Core/IsolatedPackageManager.cs b/src/VirtualClient/VirtualClient.Core/IsolatedPackageManager.cs new file mode 100644 index 0000000000..e79b4bcdb8 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Core/IsolatedPackageManager.cs @@ -0,0 +1,334 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient +{ + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using Polly; + using VirtualClient.Common.Extensions; + using VirtualClient.Contracts; + + /// + /// Provides support for package manager operations with cross-process isolation + /// and protection. + /// + public class IsolatedPackageManager : IPackageManager + { + private static readonly Mutex CrossProcessLock = new Mutex(false, "Global\\VCPackageManagerLock"); + private IPackageManager underlyingPackageManager; + + /// + /// Initializes a new instance of the class. + /// + /// The package manager to use with isolations in place. + public IsolatedPackageManager(IPackageManager packageManager) + { + packageManager.ThrowIfNull(nameof(packageManager)); + this.underlyingPackageManager = packageManager; + } + + /// + /// Provides platform-specific information. + /// + public PlatformSpecifics PlatformSpecifics + { + get + { + return this.underlyingPackageManager.PlatformSpecifics; + } + } + + /// + /// Performs extensions package discovery on the system. + /// + /// A token that can be used to cancel the operations. + public Task DiscoverExtensionsAsync(CancellationToken cancellationToken) + { + // Note: + // In order to use a Mutex (system-wide lock), the logic between the WaitOne() and ReleaseMutex() + // method calls MUST be on the same thread. We are allowing the logic to all run asynchronous using a + // Task.Run() while also ensuring the logic within that block is synchronous. + // + // Otherwise the following error will happen: "Object synchronization method was called from an unsynchronized block of code" + return Task.Run(() => + { + bool acquired = false; + try + { + if (IsolatedPackageManager.CrossProcessLock.WaitOne()) + { + acquired = true; + } + } + catch (AbandonedMutexException) + { + // This happens if the Mutex was not properly signalled on a previous run. For example, + // the application may have crashed leaving the kernel-layer mutex construct in an abandoned + // state. + } + + PlatformExtensions extensions = null; + if (acquired) + { + try + { + extensions = this.underlyingPackageManager.DiscoverExtensionsAsync(cancellationToken) + .GetAwaiter().GetResult(); + } + finally + { + IsolatedPackageManager.CrossProcessLock.ReleaseMutex(); + } + } + + return extensions; + }); + } + + /// + /// Performs package discovery on the system. + /// + /// A token that can be used to cancel the operations. + public Task> DiscoverPackagesAsync(CancellationToken cancellationToken) + { + // Note: + // In order to use a Mutex (system-wide lock), the logic between the WaitOne() and ReleaseMutex() + // method calls MUST be on the same thread. We are allowing the logic to all run asynchronous using a + // Task.Run() while also ensuring the logic within that block is synchronous. + // + // Otherwise the following error will happen: "Object synchronization method was called from an unsynchronized block of code" + bool acquired = false; + try + { + if (IsolatedPackageManager.CrossProcessLock.WaitOne()) + { + acquired = true; + } + } + catch (AbandonedMutexException) + { + // This happens if the Mutex was not properly signalled on a previous run. For example, + // the application may have crashed leaving the kernel-layer mutex construct in an abandoned + // state. We take ownership once again. + acquired = true; + } + + IEnumerable packages = null; + if (acquired) + { + try + { + packages = Task.Run(() => this.underlyingPackageManager.DiscoverPackagesAsync(cancellationToken)) + .GetAwaiter().GetResult(); + } + finally + { + IsolatedPackageManager.CrossProcessLock.ReleaseMutex(); + } + } + + return Task.FromResult(packages); + } + + /// + /// Extracts/unzips the package at the file path provided. This supports standard .zip and .nupkg + /// file formats. + /// + /// The path to the package zip file. + /// The path to the directory where the files should be extracted. + /// A token that can be used to cancel the extract operation. + /// The type of archive format the file is in. + public Task ExtractPackageAsync(string archiveFilePath, string destinationPath, CancellationToken cancellationToken, ArchiveType archiveType = ArchiveType.Zip) + { + // Note: + // In order to use a Mutex (system-wide lock), the logic between the WaitOne() and ReleaseMutex() + // method calls MUST be on the same thread. We are allowing the logic to all run asynchronous using a + // Task.Run() while also ensuring the logic within that block is synchronous. + // + // Otherwise the following error will happen: "Object synchronization method was called from an unsynchronized block of code" + bool acquired = false; + try + { + if (IsolatedPackageManager.CrossProcessLock.WaitOne()) + { + acquired = true; + } + } + catch (AbandonedMutexException) + { + // This happens if the Mutex was not properly signalled on a previous run. For example, + // the application may have crashed leaving the kernel-layer mutex construct in an abandoned + // state. We take ownership once again. + acquired = true; + } + + if (acquired) + { + try + { + Task.Run(async () => await this.underlyingPackageManager.ExtractPackageAsync(archiveFilePath, destinationPath, cancellationToken, archiveType)) + .GetAwaiter().GetResult(); + } + finally + { + IsolatedPackageManager.CrossProcessLock.ReleaseMutex(); + } + } + + return Task.CompletedTask; + } + + /// + /// Returns the package/dependency path information if it is registered. + /// + /// The name of the package dependency. + /// A token that can be used to cancel the operation. + public Task GetPackageAsync(string packageName, CancellationToken cancellationToken) + { + // Note: + // In order to use a Mutex (system-wide lock), the logic between the WaitOne() and ReleaseMutex() + // method calls MUST be on the same thread. We are allowing the logic to all run asynchronous using a + // Task.Run() while also ensuring the logic within that block is synchronous. + // + // Otherwise the following error will happen: "Object synchronization method was called from an unsynchronized block of code" + bool acquired = false; + try + { + if (IsolatedPackageManager.CrossProcessLock.WaitOne()) + { + acquired = true; + } + } + catch (AbandonedMutexException) + { + // This happens if the Mutex was not properly signalled on a previous run. For example, + // the application may have crashed leaving the kernel-layer mutex construct in an abandoned + // state. We take ownership once again. + acquired = true; + } + + DependencyPath package = null; + if (acquired) + { + try + { + package = Task.Run(() => this.underlyingPackageManager.GetPackageAsync(packageName, cancellationToken)) + .GetAwaiter().GetResult(); + } + finally + { + IsolatedPackageManager.CrossProcessLock.ReleaseMutex(); + } + } + + return Task.FromResult(package); + } + + /// + /// Performs package initialization on the system including extraction of package archives. + /// + /// A token that can be used to cancel the operations. + public Task InitializePackagesAsync(CancellationToken cancellationToken) + { + // Note: + // In order to use a Mutex (system-wide lock), the logic between the WaitOne() and ReleaseMutex() + // method calls MUST be on the same thread. We are allowing the logic to all run asynchronous using a + // Task.Run() while also ensuring the logic within that block is synchronous. + // + // Otherwise the following error will happen: "Object synchronization method was called from an unsynchronized block of code" + bool acquired = false; + try + { + if (IsolatedPackageManager.CrossProcessLock.WaitOne()) + { + acquired = true; + } + } + catch (AbandonedMutexException) + { + // This happens if the Mutex was not properly signalled on a previous run. For example, + // the application may have crashed leaving the kernel-layer mutex construct in an abandoned + // state. We take ownership once again. + acquired = true; + } + + if (acquired) + { + try + { + Task.Run(() => this.underlyingPackageManager.InitializePackagesAsync(cancellationToken)) + .GetAwaiter().GetResult(); + } + finally + { + IsolatedPackageManager.CrossProcessLock.ReleaseMutex(); + } + } + + return Task.CompletedTask; + } + + /// + /// Installs the package from the Azure storage account blob store. + /// + /// The blob manager to use for downloading the package to the file system. + /// Provides information about the target package. + /// A token that can be used to cancel the operation. + /// Optional installation path to be used to override the default installation path. + /// A retry policy to apply to the blob download and installation to allow for transient error handling. + /// The path where the Blob package was installed. + public Task InstallPackageAsync(IBlobManager packageStoreManager, DependencyDescriptor description, CancellationToken cancellationToken, string installationPath = null, IAsyncPolicy retryPolicy = null) + { + // Note: + // In order to use a Mutex (system-wide lock), the logic between the WaitOne() and ReleaseMutex() + // method calls MUST be on the same thread. We are allowing the logic to all run asynchronous using a + // Task.Run() while also ensuring the logic within that block is synchronous. + // + // Otherwise the following error will happen: "Object synchronization method was called from an unsynchronized block of code" + bool acquired = false; + try + { + if (IsolatedPackageManager.CrossProcessLock.WaitOne()) + { + acquired = true; + } + } + catch (AbandonedMutexException) + { + // This happens if the Mutex was not properly signalled on a previous run. For example, + // the application may have crashed leaving the kernel-layer mutex construct in an abandoned + // state. We take ownership once again. + acquired = true; + } + + string packagePath = null; + if (acquired) + { + try + { + packagePath = Task.Run(() => this.underlyingPackageManager.InstallPackageAsync(packageStoreManager, description, cancellationToken, installationPath, retryPolicy)) + .GetAwaiter().GetResult(); + } + finally + { + IsolatedPackageManager.CrossProcessLock.ReleaseMutex(); + } + } + + return Task.FromResult(packagePath); + } + + /// + /// Registers/saves the path so that it can be used by dependencies, workloads and monitors. Paths registered + /// follow a strict format + /// + /// Describes a package dependency to register with the system. + /// A token that can be used to cancel the operation. + public Task RegisterPackageAsync(DependencyPath package, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} diff --git a/src/VirtualClient/VirtualClient.Core/Logging/Csv/MetricsCsvFileLoggerProvider.cs b/src/VirtualClient/VirtualClient.Core/Logging/Csv/MetricsCsvFileLoggerProvider.cs index 117e894c6b..b1446d2154 100644 --- a/src/VirtualClient/VirtualClient.Core/Logging/Csv/MetricsCsvFileLoggerProvider.cs +++ b/src/VirtualClient/VirtualClient.Core/Logging/Csv/MetricsCsvFileLoggerProvider.cs @@ -5,7 +5,6 @@ namespace VirtualClient.Logging { using System; using Microsoft.Extensions.Logging; - using VirtualClient.Common; using VirtualClient.Common.Extensions; using VirtualClient.Contracts; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -14,7 +13,6 @@ namespace VirtualClient.Logging /// Provides methods for creating instances that can be used /// to write metrics data to a CSV file. /// - [Alias("Csv,File")] public sealed class MetricsCsvFileLoggerProvider : ILoggerProvider { private string filePath; diff --git a/src/VirtualClient/VirtualClient.Core/Logging/EventHub/EventHubTelemetryLoggerProvider.cs b/src/VirtualClient/VirtualClient.Core/Logging/EventHub/EventHubTelemetryLoggerProvider.cs index 28d929fddc..24ab0c2efe 100644 --- a/src/VirtualClient/VirtualClient.Core/Logging/EventHub/EventHubTelemetryLoggerProvider.cs +++ b/src/VirtualClient/VirtualClient.Core/Logging/EventHub/EventHubTelemetryLoggerProvider.cs @@ -7,6 +7,7 @@ namespace VirtualClient.Logging using Microsoft.Extensions.Logging; using VirtualClient.Common.Extensions; using VirtualClient.Common.Telemetry; + using VirtualClient.Contracts; /// /// Provides methods for creating instances that can diff --git a/src/VirtualClient/VirtualClient.Core/Logging/Serilog/SerilogFileLoggerProvider.cs b/src/VirtualClient/VirtualClient.Core/Logging/Serilog/SerilogFileLoggerProvider.cs index 7b787322a9..a4301e5cbf 100644 --- a/src/VirtualClient/VirtualClient.Core/Logging/Serilog/SerilogFileLoggerProvider.cs +++ b/src/VirtualClient/VirtualClient.Core/Logging/Serilog/SerilogFileLoggerProvider.cs @@ -9,6 +9,7 @@ namespace VirtualClient.Logging using Microsoft.Extensions.Logging; using VirtualClient.Common.Extensions; using VirtualClient.Common.Telemetry; + using VirtualClient.Contracts; using ILogger = Microsoft.Extensions.Logging.ILogger; /// diff --git a/src/VirtualClient/VirtualClient.Core/Logging/Summary/SummaryFileLoggerProvider.cs b/src/VirtualClient/VirtualClient.Core/Logging/Summary/SummaryFileLoggerProvider.cs index 9ec1c0a59e..caadf38d3c 100644 --- a/src/VirtualClient/VirtualClient.Core/Logging/Summary/SummaryFileLoggerProvider.cs +++ b/src/VirtualClient/VirtualClient.Core/Logging/Summary/SummaryFileLoggerProvider.cs @@ -40,15 +40,9 @@ public ILogger CreateLogger(string categoryName) PlatformSpecifics platformSpecifics = VirtualClientRuntime.PlatformSpecifics ?? new PlatformSpecifics(Environment.OSVersion.Platform, RuntimeInformation.ProcessArchitecture); - string experimentId = VirtualClientRuntime.ExperimentId; string logsPath = platformSpecifics.GetLogsPath(); string summaryFileName = "summary.txt"; - if (!string.IsNullOrWhiteSpace(experimentId)) - { - summaryFileName = $"{experimentId}-summary.txt"; - } - effectiveFilePath = platformSpecifics.Combine(logsPath, summaryFileName); } @@ -65,6 +59,7 @@ public ILogger CreateLogger(string categoryName) /// /// Disposes of internal resources. /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1063:Implement IDisposable Correctly", Justification = "No")] public void Dispose() { GC.SuppressFinalize(this); diff --git a/src/VirtualClient/VirtualClient.Core/ProfileExecutor.cs b/src/VirtualClient/VirtualClient.Core/ProfileExecutor.cs index ffdd695f74..9a8e0cb8c9 100644 --- a/src/VirtualClient/VirtualClient.Core/ProfileExecutor.cs +++ b/src/VirtualClient/VirtualClient.Core/ProfileExecutor.cs @@ -379,93 +379,92 @@ protected Task ExecuteActionsAsync(ProfileTiming timing, EventContext parentCont actionExecutionContext.AddContext("iterationNextExecutionTime", nextRoundOfExecution); } - await this.Logger.LogMessageAsync($"{nameof(ProfileExecutor)}.ExecuteActions", LogLevel.Debug, actionExecutionContext, async () => + this.Logger.LogMessage($"{nameof(ProfileExecutor)}.ExecuteActions", LogLevel.Debug, actionExecutionContext); + + // When we have one of the actions fail, we do not want to reset the workflow. We will move + // forward with the next action. + for (int i = 0; i < this.ProfileActions.Count(); i++) { - // When we have one of the actions fail, we do not want to reset the workflow. We will move - // forward with the next action. - for (int i = 0; i < this.ProfileActions.Count(); i++) + // Note: + // Any component can request a system reboot. The system reboot itself is handled just before the Virtual Client + // application itself exits to ensure all telemetry is captured before reboot. + if (VirtualClientRuntime.IsRebootRequested) { - // Note: - // Any component can request a system reboot. The system reboot itself is handled just before the Virtual Client - // application itself exits to ensure all telemetry is captured before reboot. - if (VirtualClientRuntime.IsRebootRequested) - { - break; - } + break; + } - if (timing.IsTimedOut) - { - break; - } + if (timing.IsTimedOut) + { + break; + } - if (!isFirstAction && nextRoundOfExecution != null) + if (!isFirstAction && nextRoundOfExecution != null) + { + while (!timing.IsTimedOut && DateTime.UtcNow < nextRoundOfExecution) { - while (!timing.IsTimedOut && DateTime.UtcNow < nextRoundOfExecution) - { - await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken) - .ConfigureAwait(false); - } + await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken) + .ConfigureAwait(false); } + } - isFirstAction = false; + isFirstAction = false; - if (!cancellationToken.IsCancellationRequested) + if (!cancellationToken.IsCancellationRequested) + { + VirtualClientComponent action = null; + + try { - VirtualClientComponent action = null; + // The context persisted here will be picked up by the individual component. This allows + // the telemetry for each round of execution of components to be correlated together while + // also being correlated with each round of profile actions processing. + EventContext.Persist(Guid.NewGuid(), actionExecutionContext.ActivityId); + + action = this.ProfileActions.ElementAt(i); + action.Parameters[nameof(VirtualClientComponent.ProfileIteration)] = currentIteration; + action.Parameters[nameof(VirtualClientComponent.ProfileIterationStartTime)] = startTime; + + this.ActionBegin?.Invoke(this, new ComponentEventArgs(action)); try { - // The context persisted here will be picked up by the individual component. This allows - // the telemetry for each round of execution of components to be correlated together while - // also being correlated with each round of profile actions processing. - EventContext.Persist(Guid.NewGuid(), actionExecutionContext.ActivityId); - - action = this.ProfileActions.ElementAt(i); - action.Parameters[nameof(VirtualClientComponent.ProfileIteration)] = currentIteration; - action.Parameters[nameof(VirtualClientComponent.ProfileIterationStartTime)] = startTime; - - this.ActionBegin?.Invoke(this, new ComponentEventArgs(action)); - - try - { - ProfileExecutor.OutputComponentStart("Action", action); - await action.ExecuteAsync(cancellationToken).ConfigureAwait(false); - } - finally - { - this.ActionEnd?.Invoke(this, new ComponentEventArgs(action)); - } + ProfileExecutor.OutputComponentStart("Action", action); + await action.ExecuteAsync(cancellationToken).ConfigureAwait(false); } - catch (VirtualClientException exc) when ((int)exc.Reason >= 500 || this.FailFast || action?.FailFast == true) + finally { - // Error reasons have numeric/integer values that indicate their severity. Error reasons - // with a value >= 500 are terminal situations where the workload cannot run successfully - // regardless of how many times we attempt it. - throw; - } - catch (VirtualClientException) - { - // Exceptions with error reasons < 500 are potentially transient issues. We do not want to - // cause VC to exit in these cases but to give it a chance to retry the logic that failed on - // subsequent rounds of processing. - // - // Exceptions having error reasons with a value between 400 - 499 are serious errors but they represent - // issues that may be transient and that can be resolve after a period of time. When - // we catch these type of errors, we may want to reset and start over in the test workflow. - } - catch (MissingMemberException exc) - { - throw new DependencyException( - "Assembly/.dll mismatch. This can occur when an extensions assembly exists in the application directory that was compiled " + - "against a version of the Virtual Client that has breaking changes. Verify the version of the extensions assemblies in the " + - "application directory can be used with the current application version. Valid extensions assemblies typically have the same " + - "major version as the application.", - exc, - ErrorReason.ExtensionAssemblyInvalid); + this.ActionEnd?.Invoke(this, new ComponentEventArgs(action)); } } + catch (VirtualClientException exc) when ((int)exc.Reason >= 500 || this.FailFast || action?.FailFast == true) + { + // Error reasons have numeric/integer values that indicate their severity. Error reasons + // with a value >= 500 are terminal situations where the workload cannot run successfully + // regardless of how many times we attempt it. + throw; + } + catch (VirtualClientException) + { + // Exceptions with error reasons < 500 are potentially transient issues. We do not want to + // cause VC to exit in these cases but to give it a chance to retry the logic that failed on + // subsequent rounds of processing. + // + // Exceptions having error reasons with a value between 400 - 499 are serious errors but they represent + // issues that may be transient and that can be resolve after a period of time. When + // we catch these type of errors, we may want to reset and start over in the test workflow. + } + catch (MissingMemberException exc) + { + throw new DependencyException( + "Assembly/.dll mismatch. This can occur when an extensions assembly exists in the application directory that was compiled " + + "against a version of the Virtual Client that has breaking changes. Verify the version of the extensions assemblies in the " + + "application directory can be used with the current application version. Valid extensions assemblies typically have the same " + + "major version as the application.", + exc, + ErrorReason.ExtensionAssemblyInvalid); + } } - }).ConfigureAwait(false); + } } finally { @@ -479,11 +478,11 @@ await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken) /// /// Executes monitors defined in the profile. /// - protected Task ExecuteMonitorsAsync(EventContext parentContext, CancellationToken cancellationToken) + protected async Task ExecuteMonitorsAsync(EventContext parentContext, CancellationToken cancellationToken) { if (!this.ExecuteMonitors || this.ProfileMonitors?.Any() != true) { - return Task.CompletedTask; + return; } List monitoringTasks = new List(); @@ -496,59 +495,58 @@ protected Task ExecuteMonitorsAsync(EventContext parentContext, CancellationToke parameters = d.Parameters?.ObscureSecrets() })); - return this.Logger.LogMessageAsync($"{nameof(ProfileExecutor)}.ExecuteMonitors", LogLevel.Debug, monitorExecutionContext, async () => + this.Logger.LogMessage($"{nameof(ProfileExecutor)}.ExecuteMonitors", LogLevel.Debug, monitorExecutionContext); + + foreach (VirtualClientComponent monitor in this.ProfileMonitors) { - foreach (VirtualClientComponent monitor in this.ProfileMonitors) + try { - try + // Note: + // Any component can request a system reboot. The system reboot itself is handled just before the Virtual Client + // application itself exits to ensure all telemetry is captured before reboot. + if (VirtualClientRuntime.IsRebootRequested) { - // Note: - // Any component can request a system reboot. The system reboot itself is handled just before the Virtual Client - // application itself exits to ensure all telemetry is captured before reboot. - if (VirtualClientRuntime.IsRebootRequested) - { - break; - } + break; + } - // The context persisted here will be picked up by the individual component. This allows - // the telemetry for each round of execution of components to be correlated together while - // also being correlated with the parent context defined at the beginning of the profile execution. - EventContext.Persist(Guid.NewGuid(), parentContext.ActivityId); + // The context persisted here will be picked up by the individual component. This allows + // the telemetry for each round of execution of components to be correlated together while + // also being correlated with the parent context defined at the beginning of the profile execution. + EventContext.Persist(Guid.NewGuid(), parentContext.ActivityId); - if (!cancellationToken.IsCancellationRequested) - { - ProfileExecutor.OutputComponentStart("Monitor", monitor); - monitoringTasks.Add(monitor.ExecuteAsync(cancellationToken)); - } - } - catch (VirtualClientException) - { - throw; - } - catch (MissingMemberException exc) - { - throw new DependencyException( - "Assembly/.dll mismatch. This can occur when an extensions assembly exists in the application directory that was compiled " + - "against a version of the Virtual Client that has breaking changes. Verify the version of the extensions assemblies in the " + - "application directory can be used with the current application version. Valid extensions assemblies typically have the same " + - "major version as the application.", - exc, - ErrorReason.ExtensionAssemblyInvalid); - } - catch (Exception exc) + if (!cancellationToken.IsCancellationRequested) { - // Error reasons have numeric/integer values that indicate their severity. Error reasons - // with a value >= 500 are terminal situations where the workload cannot run successfully - // regardless of how many times we attempt it. - throw new MonitorException( - $"Monitor execution failed for component '{monitor.TypeName}'.", - exc, - ErrorReason.DependencyInstallationFailed); + ProfileExecutor.OutputComponentStart("Monitor", monitor); + monitoringTasks.Add(monitor.ExecuteAsync(cancellationToken)); } } + catch (VirtualClientException) + { + throw; + } + catch (MissingMemberException exc) + { + throw new DependencyException( + "Assembly/.dll mismatch. This can occur when an extensions assembly exists in the application directory that was compiled " + + "against a version of the Virtual Client that has breaking changes. Verify the version of the extensions assemblies in the " + + "application directory can be used with the current application version. Valid extensions assemblies typically have the same " + + "major version as the application.", + exc, + ErrorReason.ExtensionAssemblyInvalid); + } + catch (Exception exc) + { + // Error reasons have numeric/integer values that indicate their severity. Error reasons + // with a value >= 500 are terminal situations where the workload cannot run successfully + // regardless of how many times we attempt it. + throw new MonitorException( + $"Monitor execution failed for component '{monitor.TypeName}'.", + exc, + ErrorReason.DependencyInstallationFailed); + } + } - await Task.WhenAll(monitoringTasks); - }); + await Task.WhenAll(monitoringTasks); } /// @@ -601,52 +599,51 @@ protected async Task InstallDependenciesAsync(EventContext parentContext, Cancel parameters = d.Parameters?.ObscureSecrets() })); - await this.Logger.LogMessageAsync($"{nameof(ProfileExecutor)}.InstallDependencies", LogLevel.Debug, dependencyInstallationContext, async () => + this.Logger.LogMessage($"{nameof(ProfileExecutor)}.InstallDependencies", LogLevel.Debug, dependencyInstallationContext); + + foreach (VirtualClientComponent dependency in this.ProfileDependencies) { - foreach (VirtualClientComponent dependency in this.ProfileDependencies) + if (!cancellationToken.IsCancellationRequested && !VirtualClientRuntime.IsRebootRequested) { - if (!cancellationToken.IsCancellationRequested && !VirtualClientRuntime.IsRebootRequested) + try { - try - { - // The context persisted here will be picked up by the individual component. This allows - // the telemetry for each round of execution of components to be correlated together while - // also being correlated with the parent context defined at the beginning of the profile execution. - EventContext.Persist(Guid.NewGuid(), parentContext.ActivityId); + // The context persisted here will be picked up by the individual component. This allows + // the telemetry for each round of execution of components to be correlated together while + // also being correlated with the parent context defined at the beginning of the profile execution. + EventContext.Persist(Guid.NewGuid(), parentContext.ActivityId); - ProfileExecutor.OutputComponentStart("Dependency", dependency); - await dependency.ExecuteAsync(cancellationToken).ConfigureAwait(false); - } - catch (VirtualClientException) - { - // Error reasons have numeric/integer values that indicate their severity. Error reasons - // with a value >= 500 are terminal situations where the workload cannot run successfully - // regardless of how many times we attempt it. - throw; - } - catch (MissingMemberException exc) - { - throw new DependencyException( - "Assembly/.dll mismatch. This can occur when an extensions assembly exists in the application directory that was compiled " + - "against a version of the Virtual Client that has breaking changes. Verify the version of the extensions assemblies in the " + - "application directory can be used with the current application version. Valid extensions assemblies typically have the same " + - "major version as the application.", - exc, - ErrorReason.ExtensionAssemblyInvalid); - } - catch (Exception exc) - { - // Error reasons have numeric/integer values that indicate their severity. Error reasons - // with a value >= 500 are terminal situations where the workload cannot run successfully - // regardless of how many times we attempt it. - throw new DependencyException( - $"Dependency installation failed for component '{dependency.TypeName}'.", - exc, - ErrorReason.DependencyInstallationFailed); - } + ProfileExecutor.OutputComponentStart("Dependency", dependency); + await dependency.ExecuteAsync(cancellationToken).ConfigureAwait(false); + } + catch (VirtualClientException) + { + // Error reasons have numeric/integer values that indicate their severity. Error reasons + // with a value >= 500 are terminal situations where the workload cannot run successfully + // regardless of how many times we attempt it. + throw; + } + catch (MissingMemberException exc) + { + throw new DependencyException( + "Assembly/.dll mismatch. This can occur when an extensions assembly exists in the application directory that was compiled " + + "against a version of the Virtual Client that has breaking changes. Verify the version of the extensions assemblies in the " + + "application directory can be used with the current application version. Valid extensions assemblies typically have the same " + + "major version as the application.", + exc, + ErrorReason.ExtensionAssemblyInvalid); + } + catch (Exception exc) + { + // Error reasons have numeric/integer values that indicate their severity. Error reasons + // with a value >= 500 are terminal situations where the workload cannot run successfully + // regardless of how many times we attempt it. + throw new DependencyException( + $"Dependency installation failed for component '{dependency.TypeName}'.", + exc, + ErrorReason.DependencyInstallationFailed); } } - }); + } } } diff --git a/src/VirtualClient/VirtualClient.Core/RetryPolicies.cs b/src/VirtualClient/VirtualClient.Core/RetryPolicies.cs index d38063b3f5..a4225019a0 100644 --- a/src/VirtualClient/VirtualClient.Core/RetryPolicies.cs +++ b/src/VirtualClient/VirtualClient.Core/RetryPolicies.cs @@ -6,6 +6,7 @@ namespace VirtualClient using System; using System.IO; using Polly; + using Polly.Retry; /// /// Common retry policies used by Virtual Client components to handle common @@ -30,5 +31,21 @@ public static class RetryPolicies { return true; }).WaitAndRetryAsync(10, retries => TimeSpan.FromSeconds(retries + 2)); + + /// + /// Synchronous retry policies. + /// + public static class Synchrounous + { + /// + /// Common retry policy for deleting files. + /// + public static RetryPolicy FileDelete { get; } = Policy.Handle().WaitAndRetry(5, retries => TimeSpan.FromSeconds(retries)); + + /// + /// Common retry policy for file access and operations. + /// + public static RetryPolicy FileOperations { get; } = Policy.Handle().WaitAndRetry(5, retries => TimeSpan.FromSeconds(retries)); + } } } diff --git a/src/VirtualClient/VirtualClient.Core/SshClientProxy.cs b/src/VirtualClient/VirtualClient.Core/SshClientProxy.cs index cec0991fdf..546caba909 100644 --- a/src/VirtualClient/VirtualClient.Core/SshClientProxy.cs +++ b/src/VirtualClient/VirtualClient.Core/SshClientProxy.cs @@ -21,7 +21,7 @@ namespace VirtualClient /// public class SshClientProxy : ISshClientProxy { - private static readonly Regex SshTargetExpression = new Regex(@"([0-9a-z_\-\. ]+)@([0-9a-z_\-\.]+)", RegexOptions.IgnoreCase); + private static readonly Regex SshTargetExpression = new Regex(@"([0-9a-z_\-\. ]+)@([^;]+);([\x20-\x7E]+)", RegexOptions.IgnoreCase); private static readonly Regex FileDoesNotExistExpression = new Regex("no such|not found|cannot find", RegexOptions.IgnoreCase); private bool disposed; @@ -58,6 +58,18 @@ public ConnectionInfo ConnectionInfo } } + /// + /// When defined, allows the capture of standard error from the execution of the commands through + /// the SSH session. + /// + public TextWriter StandardError { get; set; } + + /// + /// When defined, allows the capture of standard output from the execution of the commands through + /// the SSH session. + /// + public TextWriter StandardOutput { get; set; } + /// /// The underlying SSH client. /// @@ -74,17 +86,20 @@ public ConnectionInfo ConnectionInfo /// The SSH target information (e.g. user01@192.168.1.15). /// The host name/IP address (e.g. 192.168.1.15) for the SSH session. /// The username to use for the SSH session. + /// The password to use for the SSH session. /// True if the host and username information can be determined from the SSH target value. - public static bool TryGetSshTargetInformation(string sshTarget, out string host, out string username) + public static bool TryGetSshTargetInformation(string sshTarget, out string host, out string username, out string password) { host = null; username = null; + password = null; Match targetMatch = SshClientProxy.SshTargetExpression.Match(sshTarget); if (targetMatch.Success) { username = targetMatch.Groups[1].Value.Trim(); host = targetMatch.Groups[2].Value.Trim(); + password = targetMatch.Groups[3].Value.Trim(); } return host != null; @@ -389,6 +404,16 @@ public async Task ExecuteCommandAsync(string command, Cancellati try { result = await this.ExecuteCommandOnRemoteSystemAsync(command, cancellationToken, outputReceived); + + if (result != null && this.StandardError != null && result.StandardError != null) + { + await this.StandardError.WriteAsync(result.StandardError); + } + + if (result != null && this.StandardOutput != null && result.StandardOutput != null) + { + await this.StandardOutput.WriteAsync(result.StandardOutput); + } } catch (OperationCanceledException) { diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/ExecuteCommandTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/ExecuteCommandTests.cs index fc33922cc6..36516d83fe 100644 --- a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/ExecuteCommandTests.cs +++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/ExecuteCommandTests.cs @@ -10,6 +10,7 @@ namespace VirtualClient.Dependencies using System.Threading.Tasks; using Moq; using NUnit.Framework; + using VirtualClient.Common; using VirtualClient.Common.Telemetry; using VirtualClient.Contracts; @@ -133,6 +134,55 @@ public async Task ExecuteCommandSupportsCommandChainingOnUnixSystems_Bug_1(strin } } + [Test] + [TestCase( + "dos2unix install-packages.sh&&dos2unix install-python.sh&&dos2unix install-pwsh.sh&&dos2unix install-docker.sh", + "dos2unix install-packages.sh;dos2unix install-python.sh;dos2unix install-pwsh.sh;dos2unix install-docker.sh")] + public async Task ExecuteCommandSupportsCommandChainingOnUnixSystems_Bug_2(string fullCommand, string expectedCommandExecuted) + { + // Bug Scenario: + // Relative paths used to reference scripts in a specific working directory using a globally installed + // Linux toolset (e.g. dos2unix) should be left as-is. However, when a working directory is defined, that directory should + // be added to the PATH environment variable. + // + // e.g. + // (given working directory /microsoft-labs/VirtualClient/content/linux-arm64/packages/system_setup.1.0.0/linux-arm64) + // + // Should Be: + // the working + + this.SetupDefaults(PlatformID.Unix, Architecture.Arm64); + + this.mockFixture.Parameters[nameof(ExecuteCommand.WorkingDirectory)] = "{PackagePath/Platform:system_setup}"; + + this.mockFixture.PackageManager.OnGetPackage("system_setup") + .ReturnsAsync(new DependencyPath("system_setup", "/microsoft-labs/VirtualClient/content/linux-arm64/packages/system_setup.1.0.0")); + + string expectedWorkingDirectory = "/microsoft-labs/VirtualClient/content/linux-arm64/packages/system_setup.1.0.0/linux-arm64"; + + using (TestExecuteCommand command = new TestExecuteCommand(this.mockFixture)) + { + command.Parameters[nameof(ExecuteCommand.Command)] = fullCommand; + List expectedCommands = new List(expectedCommandExecuted.Split(';')); + + this.mockFixture.ProcessManager.OnProcessCreated = (process) => + { + expectedCommands.Remove(process.FullCommand()); + + // Expect: + // The working directory of the process should be set. + Assert.AreEqual(expectedWorkingDirectory, process.StartInfo.WorkingDirectory); + + // Expect: + // The working directory should be added to the PATH environment variable. + Assert.IsTrue(this.mockFixture.PlatformSpecifics.EnvironmentVariables[EnvironmentVariable.PATH].Contains(expectedWorkingDirectory)); + }; + + await command.ExecuteAsync(CancellationToken.None); + Assert.IsEmpty(expectedCommands); + } + } + [Test] [TestCase("C:\\\\Users\\User\\anycommand&&C:\\\\home\\user\\anyothercommand", "C:\\\\Users\\User\\anycommand;C:\\\\home\\user\\anyothercommand")] [TestCase("C:\\\\Users\\User\\anycommand --argument=1&&C:\\\\home\\user\\anyothercommand --argument=2", "C:\\\\Users\\User\\anycommand --argument=1;C:\\\\home\\user\\anyothercommand --argument=2")] diff --git a/src/VirtualClient/VirtualClient.Dependencies/ExecuteCommand.cs b/src/VirtualClient/VirtualClient.Dependencies/ExecuteCommand.cs index 0fc54f3c8e..9943089dc9 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/ExecuteCommand.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/ExecuteCommand.cs @@ -134,9 +134,9 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel if (!string.IsNullOrWhiteSpace(effectiveWorkingDirectory)) { this.PlatformSpecifics.SetEnvironmentVariable( - EnvironmentVariable.PATH, - effectiveWorkingDirectory, - EnvironmentVariableTarget.Process, + EnvironmentVariable.PATH, + effectiveWorkingDirectory, + EnvironmentVariableTarget.Process, append: true); } diff --git a/src/VirtualClient/VirtualClient.Main/CommandBase.cs b/src/VirtualClient/VirtualClient.Main/CommandBase.cs index d71c72f300..017a354858 100644 --- a/src/VirtualClient/VirtualClient.Main/CommandBase.cs +++ b/src/VirtualClient/VirtualClient.Main/CommandBase.cs @@ -7,7 +7,6 @@ namespace VirtualClient using System; using System.Collections.Generic; using System.Collections.ObjectModel; - using System.Configuration; using System.Data; using System.Diagnostics; using System.IO; @@ -19,7 +18,6 @@ namespace VirtualClient using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; - using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -42,8 +40,6 @@ namespace VirtualClient public abstract class CommandBase { private const string defaultPackageStoreUri = "https://virtualclient.blob.core.windows.net/packages"; - private string experimentId; - private string clientId; private IDictionary pathReplacements; /// @@ -52,6 +48,9 @@ public abstract class CommandBase protected CommandBase() { this.CertificateManager = new CertificateManager(); + this.ClientId = Environment.MachineName.ToLowerInvariant(); + this.ClientInstance = Guid.NewGuid(); + this.ExperimentId = Guid.NewGuid().ToString().ToLowerInvariant(); } /// @@ -68,18 +67,13 @@ protected CommandBase() /// The ID to use as the identifier for the agent (i.e. the instance of Virtual Client) /// and to include in telemetry output. /// - public string ClientId - { - get - { - return this.clientId; - } + public string ClientId { get; set; } - set - { - this.clientId = value?.ToLowerInvariant(); - } - } + /// + /// The ID to use as the identifier for the agent (i.e. the instance of Virtual Client) + /// and to include in telemetry output. + /// + public Guid ClientInstance { get; } /// /// Describes the target store to which content files/logs should be uploaded. @@ -122,18 +116,7 @@ public string ClientId /// /// The ID to use for the experiment and to include in telemetry output. /// - public string ExperimentId - { - get - { - return this.experimentId; - } - - set - { - this.experimentId = value?.ToLowerInvariant(); - } - } + public string ExperimentId { get; set; } /// /// True if a request to perform clean operations was requested on the @@ -155,7 +138,7 @@ public bool IsCleanRequested public bool Isolated { get; set; } /// - /// Describes the target Key vault from where secrets and certificates shold be accessed. + /// Describes the target Key vault from where secrets and certificates should be accessed. /// public string KeyVault { get; set; } @@ -238,6 +221,11 @@ public bool IsCleanRequested /// public string StateDirectory { get; set; } + /// + /// The target agents/systems to establish an SSH session (e.g. anyuser@192.168.1.15;pass_w_@rd). + /// + public IEnumerable TargetAgents { get; set; } + /// /// An alternate directory to which temp files/documents should be written. Setting this overrides /// the defaults and takes precedence over any 'VC_TEMP_DIR' environment variable values. @@ -325,6 +313,7 @@ protected async Task CleanAsync(ISystemManagement systemManagement, Cancellation await (logger ?? NullLogger.Instance).LogMessageAsync($"{commandType.Name}.CleanLogs", LogLevel.Trace, telemetryContext, async () => { await systemManagement.CleanLogsDirectoryAsync(cancellationToken, logRetentionDate); + await systemManagement.CleanContentUploadsDirectoryAsync(cancellationToken, logRetentionDate); }); } catch @@ -456,18 +445,8 @@ protected void EvaluateDirectoryPathOverrides(PlatformSpecifics platformSpecific platformSpecifics.TempDirectory = this.EvaluatePathReplacements(environmentVariableValue); } } - - if (this.Isolated) - { - string experimentIdFolder = this.ExperimentId.ToLowerInvariant(); - platformSpecifics.LogsDirectory = platformSpecifics.Combine(platformSpecifics.LogsDirectory, experimentIdFolder); - platformSpecifics.PackagesDirectory = platformSpecifics.Combine(platformSpecifics.PackagesDirectory, experimentIdFolder); - platformSpecifics.StateDirectory = platformSpecifics.Combine(platformSpecifics.StateDirectory, experimentIdFolder); - platformSpecifics.TempDirectory = platformSpecifics.Combine(platformSpecifics.TempDirectory, experimentIdFolder); - } } - /// /// Returns the full set of logger definitions to use when constructing the application /// logging facilities. @@ -543,6 +522,13 @@ protected virtual IServiceCollection InitializeDependencies(string[] args) VirtualClientRuntime.CommandLineParameters = new ReadOnlyDictionary(this.Parameters); } + // Ensure that isolation is in place for controller operations. This helps + // protect against race conditions with package management/download/extract operations. + if (this.TargetAgents?.Any() == true) + { + this.Isolated = true; + } + // Users can override the location of the "logs", "packages" and "state" folders on the command line // or by using environment variables. This is used in scenarios where VC may be used as a base for other // applications that want to have a shared resource + dependency directories. @@ -574,7 +560,8 @@ protected virtual IServiceCollection InitializeDependencies(string[] args) this.ClientId, this.ExperimentId, platformSpecifics, - logger); + logger, + this.Isolated); IApiManager apiManager = new ApiManager(systemManagement.FirewallManager); IProfileManager profileManager = new ProfileManager(); @@ -670,6 +657,22 @@ protected virtual IServiceCollection InitializeDependencies(string[] args) dependencies.AddSingleton(systemManagement); dependencies.AddSingleton(systemManagement.ProcessManager); + // Add in any SSH targets to the dependencies. + if (this.TargetAgents?.Any() == true) + { + List sshClients = new List(); + foreach (string targetAgent in this.TargetAgents) + { + if (SshClientProxy.TryGetSshTargetInformation(targetAgent, out string host, out string username, out string password)) + { + ISshClientProxy sshClient = sshClientFactory.CreateClient(host, username, password); + sshClients.Add(sshClient); + } + } + + dependencies.AddSingleton>(sshClients); + } + return dependencies; } @@ -742,7 +745,6 @@ protected virtual IList InitializeLoggerProviders(IConfiguratio /// A token that can be used to cancel the operations. protected virtual async Task InitializePackagesAsync(IPackageManager packageManager, CancellationToken cancellationToken) { - // 3) Initialize, discover and register any pre-existing packages on the system. await packageManager.InitializePackagesAsync(cancellationToken); IEnumerable packages = await packageManager.DiscoverPackagesAsync(cancellationToken); @@ -766,7 +768,7 @@ protected virtual void SetGlobalTelemetryProperties(string[] args) { [MetadataContract.ExperimentId] = this.ExperimentId, [MetadataContract.ClientId] = this.ClientId, - [MetadataContract.ClientInstance] = Guid.NewGuid().ToString().ToLowerInvariant(), + [MetadataContract.ClientInstance] = this.ClientInstance.ToString().ToLowerInvariant(), [MetadataContract.AppVersion] = extensionsVersion ?? platformVersion, [MetadataContract.AppPlatformVersion] = platformVersion, [MetadataContract.ExecutionArguments] = SensitiveData.ObscureSecrets(string.Join(" ", args)), @@ -775,8 +777,8 @@ protected virtual void SetGlobalTelemetryProperties(string[] args) [MetadataContract.PlatformArchitecture] = PlatformSpecifics.GetPlatformArchitectureName(Environment.OSVersion.Platform, RuntimeInformation.ProcessArchitecture), }); - IDictionary parameters = this.Parameters?.ObscureSecrets(); - EventContext.PersistentProperties[MetadataContract.Parameters] = parameters; + IDictionary safeParameters = this.Parameters?.ObscureSecrets(); + EventContext.PersistentProperties[MetadataContract.Parameters] = safeParameters; IDictionary metadata = new Dictionary(); @@ -788,11 +790,9 @@ protected virtual void SetGlobalTelemetryProperties(string[] args) }); } - EventContext.PersistentProperties[MetadataContract.DefaultCategory] = metadata; - - MetadataContract.Persist( - metadata?.ToDictionary(entry => entry.Key, entry => entry.Value as object), - MetadataContract.DefaultCategory); + IDictionary safeMetadata = metadata?.ObscureSecrets(); + EventContext.PersistentProperties[MetadataContract.DefaultCategory] = safeMetadata; + MetadataContract.Persist(safeMetadata, MetadataContract.DefaultCategory); } private static void AddConsoleLogging(List loggerProviders, LogLevel level) @@ -902,7 +902,7 @@ private string EvaluatePathReplacements(string path) { { "experimentId", this.ExperimentId }, { "agentId", this.ClientId }, - { "clientId", this.ClientId }, + { "clientId", this.ClientId } }; if (this.Metadata?.Any() == true) diff --git a/src/VirtualClient/VirtualClient.Main/ExecuteCommand.cs b/src/VirtualClient/VirtualClient.Main/ExecuteCommand.cs index 166723a71c..d174eed69b 100644 --- a/src/VirtualClient/VirtualClient.Main/ExecuteCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/ExecuteCommand.cs @@ -5,6 +5,7 @@ namespace VirtualClient { using System; using System.Collections.Generic; + using System.CommandLine; using System.IO; using System.Linq; using System.Text.RegularExpressions; diff --git a/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs b/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs index dc3967bd1b..9f1f0ab60e 100644 --- a/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs @@ -166,12 +166,10 @@ public override async Task ExecuteAsync(string[] args, CancellationTokenSou } catch (VirtualClientException exc) { - Program.LogErrorMessage(logger, exc, EventContext.Persisted()); exitCode = (int)exc.Reason; } - catch (Exception exc) + catch { - Program.LogErrorMessage(logger, exc, EventContext.Persisted()); exitCode = 1; } finally diff --git a/src/VirtualClient/VirtualClient.Main/ExecuteRemoteAgentCommand.cs b/src/VirtualClient/VirtualClient.Main/ExecuteRemoteAgentCommand.cs new file mode 100644 index 0000000000..2fe9eb5cbd --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/ExecuteRemoteAgentCommand.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient +{ + using System; + using System.Collections.Generic; + using System.CommandLine; + using System.IO; + using System.IO.Abstractions; + using System.Linq; + using System.Text.RegularExpressions; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + using VirtualClient.Common.Telemetry; + using VirtualClient.Contracts; + using VirtualClient.Logging; + + /// + /// Command runs the full Virtual Client command line on a target system. + /// + internal class ExecuteRemoteAgentCommand : ExecuteProfileCommand + { + /// + /// A command line to execute independently of a profile. + /// + public string Command { get; set; } + + /// + /// Executes the operations to reset the environment. + /// + /// The arguments provided to the application on the command line. + /// Provides a token that can be used to cancel the command operations. + /// The exit code for the command operations. + public override Task ExecuteAsync(string[] args, CancellationTokenSource cancellationTokenSource) + { + if (this.Profiles?.Any() != true && string.IsNullOrWhiteSpace(this.Command)) + { + throw new ArgumentException("Invalid usage. Either a profile or a command must be defined on the command line."); + } + + this.Profiles = new List + { + new DependencyProfileReference("EXECUTE-COMMAND-ON-REMOTE.json") + }; + + // To avoid confusing situations, remote command execution DOES NOT support + // the following features on the controller/local system: + // - Dependency installation on the controller. + // - Targeting specific scenarios (e.g. --scenarios=Scenario01). + this.InstallDependencies = false; + this.Scenarios = null; + + this.Iterations = ProfileTiming.OneIteration(); + this.Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { nameof(this.Command), this.GetTargetCommandArguments(args) } + }; + + return base.ExecuteAsync(args, cancellationTokenSource); + } + + /// + /// Returns the command line arguments to execute on the target/remote system. + /// + /// The original/full command line arguments. + protected string GetTargetCommandArguments(string[] commandArguments) + { + List targetCommandArguments = new List(); + + Option targetAgentOption = OptionFactory.CreateTargetAgentOption(); + Regex subCommandExpression = new Regex("remote"); + + foreach (string argument in commandArguments) + { + if (!string.IsNullOrWhiteSpace(this.Command) && argument == this.Command) + { + targetCommandArguments.Add($"\"{argument}\""); + } + else if (!OptionFactory.ContainsOption(targetAgentOption, argument) && !subCommandExpression.IsMatch(argument)) + { + // Remove the remote-execute subcommand and SSH options. + targetCommandArguments.Add(argument); + } + } + + return string.Join(" ", targetCommandArguments); + } + } +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs index 93de5156c4..7ddc9999ef 100644 --- a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs +++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs @@ -12,6 +12,7 @@ namespace VirtualClient using System.IO.Abstractions; using System.Linq; using System.Net; + using System.Text.RegularExpressions; using Microsoft.CodeAnalysis; using Microsoft.Extensions.Logging; using VirtualClient.Common.Extensions; @@ -29,6 +30,40 @@ public static class OptionFactory private static readonly IFileSystem defaultFileSystem = new FileSystem(); private static readonly char[] argumentTrimChars = new char[] { '\'', '"', ' ' }; + /// + /// Checks to determine if the command line arguments contains the option provided. + /// + /// The option to check for existence. + /// The command line arguments. + /// True if the command line arguments contains the option. False if not. + public static bool ContainsOption(Option option, params string[] args) + { + option.ThrowIfNull(nameof(option)); + bool hasOption = false; + + if (args?.Any() == true) + { + // e.g. + // --profile -> p, profile + string optionAliases = string.Join("|", option.Aliases + .Where(a => a.StartsWith("--")) + .Select(a => a.Substring(2))); + + Regex matchExpression = new Regex($@"--(?:{optionAliases})(?:[=\s]{{1}})|--(?:{optionAliases})$"); + + foreach (string arg in args) + { + if (!string.IsNullOrWhiteSpace(arg) && matchExpression.IsMatch(arg)) + { + hasOption = true; + break; + } + } + } + + return hasOption; + } + /// /// Command line option defines the port on which the local self-hosted REST API service /// should list for HTTP traffic. @@ -140,7 +175,10 @@ public static Option CreateClientIdOption(bool required = false, object defaultV { // Note: // Only the first 2 of these will display in help output (i.e. --help). - Option option = new Option(new string[] { "--c", "--client", "--client-id", "--clientId", "--clientid", "--agent-id", "--agentId", "--agentid" }) + // + // **IMPORTANT** + // Note that the --agentId option will be deprecated in the future. + Option option = new Option(new string[] { "--c", "--client-id", "--agentId" }) { Name = "ClientId", Description = "A name/identifier to describe the instance of the application (the agent) that will be included with all " + @@ -181,7 +219,7 @@ public static Option CreateContentPathTemplateOption(bool required = true, objec { // Note: // Only the first 3 of these will display in help output (i.e. --help). - Option option = new Option(new string[] { "--cp", "--content-path", "--content-path-template", "--contentPathTemplate", "--contentpathtemplate", "--contentPath", "--contentpath" }) + Option option = new Option(new string[] { "--cp", "--content-path", "--content-path-template" }) { Name = "ContentPathTemplate", Description = "A template defining the virtual folder structure to use when uploading files to a target storage account. Default = /{experimentId}/{agentId}/{toolName}/{role}/{scenario}.", @@ -212,7 +250,7 @@ public static Option CreateContentStoreOption(bool required = true, object defau // Note: // Only the first 3 of these will display in help output (i.e. --help). Option option = new Option( - new string[] { "--cs", "--content", "--content-store", "--contentStore", "--contentstore", }, + new string[] { "--cs", "--content", "--content-store" }, new ParseArgument(result => OptionFactory.ParseBlobStore( result, DependencyStore.Content, @@ -366,8 +404,11 @@ public static Option CreateEventHubStoreOption(bool required = false, object def { // Note: // Only the first 3 of these will display in help output (i.e. --help). + // + // **IMPORTANT** + // Note that this option will be deprecated in the future. Option option = new Option( - new string[] { "--eh", "--eventhub", "--event-hub", "--eventHub", "--eventHubConnectionString", "--eventhubconnectionstring" }) + new string[] { "--event-hub", "--eventHubConnectionString" }) { Name = "EventHubStore", Description = "An endpoint URI or connection string/access policy defining an Event Hub to which telemetry should be sent/uploaded.", @@ -390,7 +431,7 @@ public static Option CreateExitWaitOption(bool required = true, object defaultVa // Note: // Only the first 3 of these will display in help output (i.e. --help). Option option = new Option( - new string[] { "--wait", "--exit-wait", "--flush-wait" }, + new string[] { "--wait", "--exit-wait" }, new ParseArgument(arg => OptionFactory.ParseTimeSpan(arg))) { Name = "ExitWait", @@ -414,7 +455,10 @@ public static Option CreateExperimentIdOption(bool required = false, object defa { // Note: // Only the first 3 of these will display in help output (i.e. --help). - Option option = new Option(new string[] { "--e", "--experiment", "--experiment-id", "--experimentId", "--experimentid" }) + // + // **IMPORTANT** + // Note that the --experimentId option will be deprecated in the future. + Option option = new Option(new string[] { "--e", "--experiment-id", "--experimentId" }) { Name = "ExperimentId", Description = "An identifier that will be used to correlate all operations with telemetry/data emitted by the application. If not defined, a random identifier will be used.", @@ -501,27 +545,6 @@ public static Option CreateIPAddressOption(bool required = true, object defaultV return option; } - /// - /// Command line option indicates that the application should run with isolated logs, packages, - /// state and temp directories. - /// - /// Sets this option as required. - /// Sets the default value when none is provided. - public static Option CreateIsolatedFlag(bool required = true, object defaultValue = null) - { - Option option = new Option(new string[] { "--isolated" }) - { - Name = "Isolated", - Description = "Flag indicates that the application should run with isolated logs, packages, state and temp directories.", - ArgumentHelpName = "Flag", - AllowMultipleArgumentsPerToken = false, - }; - - OptionFactory.SetOptionRequirements(option, required, defaultValue); - - return option; - } - /// /// Command line option defines the number of rounds/iterations to run the profile actions. /// @@ -584,7 +607,7 @@ public static Option CreateLayoutPathOption(bool required = true, object default { // Note: // Only the first 3 of these will display in help output (i.e. --help). - Option option = new Option(new string[] { "--lp", "--layout", "--layout-path", "--layoutPath", "--layoutpath", }) + Option option = new Option(new string[] { "--lp", "--layout", "--layout-path", }) { Name = "LayoutPath", Description = "The path to the environment layout .json file required for client/server operations. The contents of this " + @@ -899,7 +922,7 @@ public static Option CreatePackageStoreOption(bool required = true, object defau // Note: // Only the first 3 of these will display in help output (i.e. --help). Option option = new Option( - new string[] { "--ps", "--packages", "--package-store", "--packageStore", "--packagestore" }, + new string[] { "--ps", "--packages", "--package-store" }, new ParseArgument(result => OptionFactory.ParseBlobStore( result, DependencyStore.Packages, @@ -1169,6 +1192,42 @@ public static Option CreateSystemOption(bool required = true, object defaultValu return option; } + /// + /// Command line option defines the target agent SSH connection information (e.g. anyuser@192.168.1.15;pass_w_@rd). + /// + /// Sets this option as required. + /// Sets the default value when none is provided. + public static Option CreateTargetAgentOption(bool required = false, object defaultValue = null) + { + Option> option = new Option>(new string[] { "--ssh", "--agent-ssh" }) + { + Name = "TargetAgents", + Description = "The target agent/system SSH connection information (e.g. anyuser@192.168.1.15;pass_w_@rd).", + ArgumentHelpName = "target", + AllowMultipleArgumentsPerToken = true + }; + + option.AddValidator(result => + { + foreach (Token token in result.Tokens) + { + string agentSsh = token.Value; + if (!SshClientProxy.TryGetSshTargetInformation(agentSsh, out string host, out string username, out string pass)) + { + throw new NotSupportedException( + $"Invalid target agent SSH definition. The SSH host, username or password information provided is not a valid format. " + + $"Agent SSH targets should be in a format similar to the following example: user@192.168.1.15;pw@rd"); + } + } + + return string.Empty; + }); + + OptionFactory.SetOptionRequirements(option, required, defaultValue); + + return option; + } + /// /// Command line option defines a target directory. /// @@ -1658,6 +1717,14 @@ private static void ThrowIfOptionExists(OptionResult parsedResult, string option } } + private static void ThrowIfOptionDoesNotExists(OptionResult parsedResult, string optionName, string errorMessage) + { + if (parsedResult.Parent?.Children?.Any(option => string.Equals(option.Symbol.Name, optionName, StringComparison.OrdinalIgnoreCase)) != true) + { + throw new ArgumentException(errorMessage); + } + } + private static string ToFullPath(string path) { string fullPath = path; diff --git a/src/VirtualClient/VirtualClient.Main/Program.cs b/src/VirtualClient/VirtualClient.Main/Program.cs index 8a49abb65e..404abae2e7 100644 --- a/src/VirtualClient/VirtualClient.Main/Program.cs +++ b/src/VirtualClient/VirtualClient.Main/Program.cs @@ -9,17 +9,18 @@ namespace VirtualClient using System.CommandLine.Builder; using System.CommandLine.Invocation; using System.CommandLine.Parsing; + using System.Diagnostics; using System.Diagnostics.CodeAnalysis; + using System.Linq; using System.Runtime.InteropServices; using System.ServiceProcess; - using System.Text; + using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using global::VirtualClient.Contracts; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; - using Org.BouncyCastle.Bcpg.OpenPgp; using VirtualClient.Common.Telemetry; using VirtualClient.Configuration; using VirtualClient.Logging; @@ -46,6 +47,8 @@ public static int Main(string[] args) try { + VirtualClientRuntime.CommandLineArguments = args; + // We want to ensure that the platform on which we are running is actually supported. PlatformSpecifics.ThrowIfNotSupported(Environment.OSVersion.Platform); PlatformSpecifics.ThrowIfNotSupported(RuntimeInformation.ProcessArchitecture); @@ -92,16 +95,13 @@ public static int Main(string[] args) // On Windows systems, this is required when running Virtual Client as a service. // Certain notifications have to be sent to the Windows service control manager (SCM) // in order to ensure the service is recognized as running. - Task serviceHostTask = Task.Run(() => + if (Environment.OSVersion.Platform == PlatformID.Win32NT && Program.IsRunningAsService(Environment.OSVersion.Platform)) { - if (Program.IsRunningAsService()) + Task serviceHostTask = Task.Run(() => { - if (Environment.OSVersion.Platform == PlatformID.Win32NT) - { - Program.RunAsWindowsService(cancellationSource); - } - } - }); + Program.RunAsWindowsService(cancellationSource); + }); + } exitCode = executionTask.GetAwaiter().GetResult(); } @@ -189,11 +189,12 @@ internal static void LogErrorMessage(ILogger logger, Exception exc, EventContext internal static CommandLineBuilder SetupCommandLine(string[] args, CancellationTokenSource cancellationTokenSource) { DefaultSettings settings = Program.Settings ?? DefaultSettings.Create(); + RootCommand rootCommand = new RootCommand("Executes workload and monitoring profiles on the system.") { // OPTIONAL // ------------------------------------------------------------------- - // --profile + // --profile OptionFactory.CreateProfileOption(required: false), // --api-port @@ -226,9 +227,6 @@ internal static CommandLineBuilder SetupCommandLine(string[] args, CancellationT // --fail-fast OptionFactory.CreateFailFastFlag(required: false), - // --isolated - OptionFactory.CreateIsolatedFlag(required: false), - // --iterations OptionFactory.CreateIterationsOption(required: false), @@ -280,10 +278,13 @@ internal static CommandLineBuilder SetupCommandLine(string[] args, CancellationT // --system OptionFactory.CreateSystemOption(required: false), + // --agent-ssh + OptionFactory.CreateTargetAgentOption(required: false), + // --temp-dir OptionFactory.CreateTempDirectoryOption(required: false, settings.TempDirectory), - // --timeout + // --timeout OptionFactory.CreateTimeoutOption(required: false), // --verbose @@ -317,6 +318,11 @@ internal static CommandLineBuilder SetupCommandLine(string[] args, CancellationT convertSubcommand.Handler = CommandHandler.Create(cmd => cmd.ExecuteAsync(args, cancellationTokenSource)); rootCommand.Add(convertSubcommand); + Command remoteSubcommand = Program.CreateRemoteSubcommand(settings); + remoteSubcommand.TreatUnmatchedTokensAsErrors = true; + remoteSubcommand.Handler = CommandHandler.Create(cmd => cmd.ExecuteAsync(args, cancellationTokenSource)); + rootCommand.Add(remoteSubcommand); + Command uploadTelemetrySubcommand = Program.CreateUploadTelemetrySubcommand(settings); uploadTelemetrySubcommand.TreatUnmatchedTokensAsErrors = true; uploadTelemetrySubcommand.Handler = CommandHandler.Create(cmd => cmd.ExecuteAsync(args, cancellationTokenSource)); @@ -342,9 +348,6 @@ private static Command CreateApiSubcommand(DefaultSettings settings) // --ip-address OptionFactory.CreateIPAddressOption(required: false), - // --isolated - OptionFactory.CreateIsolatedFlag(required: false), - // --log-dir OptionFactory.CreateLogDirectoryOption(required: false, settings.LogDirectory), @@ -382,14 +385,14 @@ private static Command CreateBootstrapSubcommand(DefaultSettings settings) "bootstrap", "Bootstraps/installs a dependency package on the system.") { - // Required + // REQUIRED // ------------------------------------------------------------------- // --package OptionFactory.CreatePackageOption(required: true), // OPTIONAL // ------------------------------------------------------------------- - // --clean + // --clean OptionFactory.CreateCleanOption(required: false), // --client-id @@ -404,9 +407,6 @@ private static Command CreateBootstrapSubcommand(DefaultSettings settings) // --experiment-id OptionFactory.CreateExperimentIdOption(required: false, Guid.NewGuid().ToString()), - // --isolated - OptionFactory.CreateIsolatedFlag(required: false), - // --iterations (for integration only. not used/always = 1) OptionFactory.CreateIterationsOption(required: false), @@ -510,7 +510,7 @@ private static Command CreateConvertSubcommand(DefaultSettings settings) "convert", "Converts execution profiles from JSON to YAML format and vice-versa.") { - // Required + // REQUIRED // ------------------------------------------------------------------- // --profile OptionFactory.CreateProfileOption(required: true), @@ -522,13 +522,120 @@ private static Command CreateConvertSubcommand(DefaultSettings settings) return convertCommand; } + private static Command CreateRemoteSubcommand(DefaultSettings settings) + { + Command remoteExecuteCommand = new Command( + "remote", + "Executes workload and monitoring profiles on a remote/target system through an SSH connection.") + { + // REQUIRED + // ------------------------------------------------------------------- + // --agent-ssh + OptionFactory.CreateTargetAgentOption(required: true), + + // OPTIONAL + // ------------------------------------------------------------------- + // --profile + OptionFactory.CreateProfileOption(required: false), + + // --api-port + OptionFactory.CreateApiPortOption(required: false), + + // --clean + OptionFactory.CreateCleanOption(required: false), + + // --client-id + OptionFactory.CreateClientIdOption(required: false, Environment.MachineName), + + // --content-store + OptionFactory.CreateContentStoreOption(required: false), + + // --content-path-template + OptionFactory.CreateContentPathTemplateOption(required: false), + + // --dependencies + OptionFactory.CreateDependenciesFlag(required: false), + + // --event-hub + OptionFactory.CreateEventHubStoreOption(required: false), + + // --experiment-id + OptionFactory.CreateExperimentIdOption(required: false, Guid.NewGuid().ToString()), + + // --exit-wait + OptionFactory.CreateExitWaitOption(required: false, TimeSpan.FromMinutes(30)), + + // --fail-fast + OptionFactory.CreateFailFastFlag(required: false), + + // --iterations + OptionFactory.CreateIterationsOption(required: false), + + // --layout-path + OptionFactory.CreateLayoutPathOption(required: false), + + // --log-dir + OptionFactory.CreateLogDirectoryOption(required: false, settings.LogDirectory), + + // --logger + OptionFactory.CreateLoggerOption(required: false, settings.Loggers), + + // --log-level + OptionFactory.CreateLogLevelOption(required: false, LogLevel.Information), + + // --log-retention + OptionFactory.CreateLogRetentionOption(required: false), + + // --log-to-file + OptionFactory.CreateLogToFileFlag(required: false, settings.LogToFile), + + // --metadata + OptionFactory.CreateMetadataOption(required: false), + + // --package-dir + OptionFactory.CreatePackageDirectoryOption(required: false, settings.PackageDirectory), + + // --package-store + OptionFactory.CreatePackageStoreOption(required: false), + + // --parameters + OptionFactory.CreateParametersOption(required: false), + + // --proxy-api + OptionFactory.CreateProxyApiOption(required: false), + + // --scenarios + OptionFactory.CreateScenariosOption(required: false), + + // --seed + OptionFactory.CreateSeedOption(required: false, 777), + + // --state-dir + OptionFactory.CreateStateDirectoryOption(required: false, settings.StateDirectory), + + // --system + OptionFactory.CreateSystemOption(required: false), + + // --temp-dir + OptionFactory.CreateTempDirectoryOption(required: false, settings.TempDirectory), + + // --timeout + OptionFactory.CreateTimeoutOption(required: false), + + // --verbose + OptionFactory.CreateVerboseFlag(required: false, false) + }; + + return remoteExecuteCommand; + } + private static Command CreateUploadTelemetrySubcommand(DefaultSettings settings) { Command uploadTelemetryCommand = new Command( "upload-telemetry", "Uploads telemetry (e.g. events, metrics) from data point files on the system.") { - // Required + // REQUIRED // ------------------------------------------------------------------- // --format OptionFactory.CreateDataFormatOption(required: true), @@ -601,9 +708,18 @@ private static void InitializeStartupLogging(string[] args) Program.Logger = new LoggerFactory(loggerProviders).CreateLogger("VirtualClient"); } - private static bool IsRunningAsService() + private static bool IsRunningAsService(PlatformID platform) { - return !Environment.UserInteractive; + bool isService = false; + if (platform == PlatformID.Win32NT) + { + // Windows services run as child processes of the "services.exe" module. + int currentProcessId = Process.GetCurrentProcess().Id; + int parentProcessId = WindowsServiceHost.GetParentProcessId(currentProcessId); + isService = currentProcessId != parentProcessId; + } + + return isService; } private static void RunAsWindowsService(CancellationTokenSource cancellationTokenSource) @@ -637,6 +753,25 @@ public WindowsServiceHost(ILogger logger, CancellationTokenSource cancellationTo this.logger = logger ?? NullLogger.Instance; } + internal static int GetParentProcessId(int processId) + { + IntPtr hProcess = NativeMethods.OpenProcess(ProcessAccessFlags.QueryLimitedInformation, false, processId); + if (hProcess == IntPtr.Zero) return -1; + + try + { + PROCESS_BASIC_INFORMATION pbi = new PROCESS_BASIC_INFORMATION(); + int returnLength; + int status = NativeMethods.NtQueryInformationProcess(hProcess, 0, ref pbi, Marshal.SizeOf(pbi), out returnLength); + + return (status == 0) ? pbi.ParentProcessId : -1; + } + finally + { + NativeMethods.CloseHandle(hProcess); + } + } + /// protected override void OnStart(string[] args) { @@ -674,10 +809,20 @@ private void SetStatus(ServiceState state) NativeMethods.SetServiceStatus(this.ServiceHandle, ref serviceStatus); } - private static class NativeMethods + internal static class NativeMethods { [DllImport("advapi32.dll", SetLastError = true)] internal static extern bool SetServiceStatus(IntPtr handle, ref ServiceStatus serviceStatus); + + [DllImport("ntdll.dll")] + internal static extern int NtQueryInformationProcess(IntPtr processHandle, int processInformationClass, + ref PROCESS_BASIC_INFORMATION processInformation, int processInformationLength, out int returnLength); + + [DllImport("kernel32.dll")] + internal static extern IntPtr OpenProcess(ProcessAccessFlags processAccess, bool bInheritHandle, int processId); + + [DllImport("kernel32.dll")] + internal static extern bool CloseHandle(IntPtr hObject); } internal enum ServiceState @@ -705,6 +850,22 @@ internal struct ServiceStatus public long CheckPoint; public long WaitHint; } + + internal struct PROCESS_BASIC_INFORMATION + { + public IntPtr Reserved1; + public IntPtr PebBaseAddress; + public IntPtr Reserved2; + public IntPtr Reserved3; + public int ParentProcessId; + public IntPtr Reserved4; + } + + [Flags] + internal enum ProcessAccessFlags : uint + { + QueryLimitedInformation = 0x1000 + } } } } \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Main/VirtualClient.Main.csproj b/src/VirtualClient/VirtualClient.Main/VirtualClient.Main.csproj index c628dd3d76..376b4b33cc 100644 --- a/src/VirtualClient/VirtualClient.Main/VirtualClient.Main.csproj +++ b/src/VirtualClient/VirtualClient.Main/VirtualClient.Main.csproj @@ -22,6 +22,7 @@ + diff --git a/src/VirtualClient/VirtualClient.Main/appsettings-agent.json b/src/VirtualClient/VirtualClient.Main/appsettings-agent.json new file mode 100644 index 0000000000..fae6901b35 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/appsettings-agent.json @@ -0,0 +1,15 @@ +{ + "DefaultSettings": { + "LogToFile": true, + "LogDirectory": "../logs", + "PackageDirectory": "../packages", + "StateDirectory": "./state", + "TempDirectory": "./temp" + }, + "EventHubLogSettings": { + "EventsHubName": "telemetry-events", + "MetricsHubName": "telemetry-metrics", + "TracesHubName": "telemetry-logs", + "IsEnabled": true + } +} diff --git a/src/VirtualClient/VirtualClient.Main/appsettings.json b/src/VirtualClient/VirtualClient.Main/appsettings.json index fbd78bfd5a..9ad913303b 100644 --- a/src/VirtualClient/VirtualClient.Main/appsettings.json +++ b/src/VirtualClient/VirtualClient.Main/appsettings.json @@ -1,4 +1,10 @@ { + "DefaultSettings": { + "LogDirectory": "./logs", + "PackageDirectory": "./packages", + "StateDirectory": "./state", + "TempDirectory": "./temp" + }, "EventHubLogSettings": { "EventsHubName": "telemetry-events", "MetricsHubName": "telemetry-metrics", diff --git a/src/VirtualClient/VirtualClient.Main/profiles/EXAMPLE-CONTROLLER-AGENT-WORKFLOW.json b/src/VirtualClient/VirtualClient.Main/profiles/EXAMPLE-CONTROLLER-AGENT-WORKFLOW.json new file mode 100644 index 0000000000..e24f7e47ca --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/profiles/EXAMPLE-CONTROLLER-AGENT-WORKFLOW.json @@ -0,0 +1,29 @@ +{ + "Description": "Provides example of a controller/agent workflow with steps that execute on both controller and target systems.", + "Metadata": { + "SupportedPlatforms": "linux-x64,linux-arm64,win-x64,win-arm64" + }, + "Parameters": { + "Command": null, + "SshTarget": null + }, + "Actions": [ + { + "Type": "RemoteAgentExecutor", + "Parameters": { + "Scenario": "ExecuteAgentOnRemoteSystem", + "Command": "$.Parameters.Command", + "SshTarget": "$.Parameters.SshTarget", + "Tags": "VC,SSH,Controller" + } + }, + { + "Type": "RemoteAgentLogCopy", + "Parameters": { + "Scenario": "CopyLogsFromRemoteSystem", + "SshTarget": "$.Parameters.SshTarget", + "Tags": "VC,SSH,Controller" + } + } + ] +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Main/profiles/EXECUTE-COMMAND-ON-REMOTE.json b/src/VirtualClient/VirtualClient.Main/profiles/EXECUTE-COMMAND-ON-REMOTE.json new file mode 100644 index 0000000000..422a856b40 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/profiles/EXECUTE-COMMAND-ON-REMOTE.json @@ -0,0 +1,23 @@ +{ + "Description": "Executes a command through the agent on a remote system.", + "Metadata": { + "SupportedPlatforms": "linux-x64,linux-arm64,win-x64,win-arm64" + }, + "Parameters": { + "Command": null, + "SshTarget": null, + "SshPassword": null + }, + "Actions": [ + { + "Type": "RemoteAgentExecutor", + "Parameters": { + "Scenario": "ExecuteAgentOnTarget", + "Command": "$.Parameters.Command", + "SshTarget": "$.Parameters.SshTarget", + "SshPassword": "$.Parameters.SshPassword", + "Tags": "VC,SSH,Controller" + } + } + ] +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-AGENT.json b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-AGENT.json new file mode 100644 index 0000000000..ef11d738cf --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-AGENT.json @@ -0,0 +1,30 @@ +{ + "Description": "Installs the agent on a remote system.", + "Metadata": { + "SupportedPlatforms": "linux-x64,linux-arm64,win-x64,win-arm64" + }, + "Parameters": { + "SshTarget": null, + "SshPassword": null + }, + "Actions": [ + { + "Type": "WaitExecutor", + "Parameters": { + "Scenario": "Wait", + "Duration": "00:00:01" + } + } + ], + "Dependencies": [ + { + "Type": "RemoteAgentInstallation", + "Parameters": { + "Scenario": "InstallAgentOnTarget", + "SshTarget": "$.Parameters.SshTarget", + "SshPassword": "$.Parameters.SshPassword", + "Tags": "VC,SSH,Controller" + } + } + ] +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-PACKAGES.json b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-PACKAGES.json new file mode 100644 index 0000000000..f4efb0b385 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-PACKAGES.json @@ -0,0 +1,30 @@ +{ + "Description": "Installs the agent packages on a remote system.", + "Metadata": { + "SupportedPlatforms": "linux-x64,linux-arm64,win-x64,win-arm64" + }, + "Parameters": { + "SshTarget": null, + "SshPassword": null + }, + "Actions": [ + { + "Type": "WaitExecutor", + "Parameters": { + "Scenario": "Initialize", + "Duration": "00:00:01" + } + } + ], + "Dependencies": [ + { + "Type": "RemotePackagesInstallation", + "Parameters": { + "Scenario": "InstallPackagesOnTarget", + "SshTarget": "$.Parameters.SshTarget", + "SshPassword": "$.Parameters.SshPassword", + "Tags": "VC,SSH,Controller" + } + } + ] +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-POWERSHELL.json b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-POWERSHELL.json new file mode 100644 index 0000000000..90b1f7cf9c --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-POWERSHELL.json @@ -0,0 +1,43 @@ +{ + "Description": "Installs the PowerShell 7 (pwsh.exe) on a remote system.", + "Metadata": { + "SupportedPlatforms": "linux-x64,linux-arm64,win-x64,win-arm64" + }, + "Parameters": { + "SshTarget": null, + "SshPassword": null + }, + "Actions": [ + { + "Type": "WaitExecutor", + "Parameters": { + "Scenario": "Initialize", + "Duration": "00:00:01" + } + } + ], + "Dependencies": [ + { + "Type": "RemoteAgentExecutor", + "Parameters": { + "Scenario": "InstallPwshOnTarget", + "Command": "\"{PackagePath/Platform:system_setup}/install-pwsh.sh\"", + "SshTarget": "$.Parameters.SshTarget", + "SshPassword": "$.Parameters.SshPassword", + "SupportedPlatforms": "linux-arm64,linux-x64", + "Tags": "VC,SSH,Controller,PowerShell" + } + }, + { + "Type": "RemoteAgentExecutor", + "Parameters": { + "Scenario": "InstallPwshOnTarget", + "Command": "\"{PackagePath/Platform:system_setup}\\install-pwsh.cmd\"", + "SshTarget": "$.Parameters.SshTarget", + "SshPassword": "$.Parameters.SshPassword", + "SupportedPlatforms": "win-arm64,win-x64", + "Tags": "VC,SSH,Controller,PowerShell" + } + } + ] +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-PYTHON.json b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-PYTHON.json new file mode 100644 index 0000000000..4a3bcd7632 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-PYTHON.json @@ -0,0 +1,32 @@ +{ + "Description": "Installs the Python 3 on a remote system.", + "Metadata": { + "SupportedPlatforms": "win-x64,win-arm64" + }, + "Parameters": { + "SshTarget": null, + "SshPassword": null + }, + "Actions": [ + { + "Type": "WaitExecutor", + "Parameters": { + "Scenario": "Initialize", + "Duration": "00:00:01" + } + } + ], + "Dependencies": [ + { + "Type": "RemoteAgentExecutor", + "Parameters": { + "Scenario": "InstallPythonOnTarget", + "Command": "\"{PackagePath/Platform:system_setup}\\install-python.cmd\"", + "SshTarget": "$.Parameters.SshTarget", + "SshPassword": "$.Parameters.SshPassword", + "SupportedPlatforms": "win-arm64,win-x64", + "Tags": "VC,SSH,Controller,Python" + } + } + ] +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.TestFramework/InMemorySshClient.cs b/src/VirtualClient/VirtualClient.TestFramework/InMemorySshClient.cs index b593e64aa5..63a3deaadd 100644 --- a/src/VirtualClient/VirtualClient.TestFramework/InMemorySshClient.cs +++ b/src/VirtualClient/VirtualClient.TestFramework/InMemorySshClient.cs @@ -97,6 +97,12 @@ public InMemorySshClient(ConnectionInfo connection) /// public Func> OnGetTargetPlatformArchitecture { get; set; } + /// + public TextWriter StandardError { get; set; } + + /// + public TextWriter StandardOutput { get; set; } + /// public Task ConnectAsync(CancellationToken cancellationToken = default(CancellationToken)) { diff --git a/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs b/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs index cb9ae525f2..1bcc148ad1 100644 --- a/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs +++ b/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs @@ -373,6 +373,14 @@ public string GetProfileDownloadsPath(params string[] pathSegments) return this.PlatformSpecifics.GetProfileDownloadsPath(pathSegments); } + /// + /// Combines the path segments into a valid state file path. + /// + public string GetStatePath(params string[] pathSegments) + { + return this.PlatformSpecifics.GetStatePath(pathSegments); + } + /// /// Combines the path segments into a valid default temp path. /// diff --git a/src/VirtualClient/VirtualClient.UnitTests/CommandBaseTests.cs b/src/VirtualClient/VirtualClient.UnitTests/CommandBaseTests.cs index b7a43f130d..ad44ddf0ed 100644 --- a/src/VirtualClient/VirtualClient.UnitTests/CommandBaseTests.cs +++ b/src/VirtualClient/VirtualClient.UnitTests/CommandBaseTests.cs @@ -36,9 +36,16 @@ public void SetupTest(PlatformID platform, Architecture architecture) this.mockFixture = new MockFixture(); this.mockFixture.Setup(platform, architecture); this.mockFixture.SetupCertificateMocks(); + + this.ExperimentId = null; + this.LogDirectory = null; + this.PackageDirectory = null; + this.StateDirectory = null; + this.TempDirectory = null; } [Test] + [Order(0)] [TestCase(PlatformID.Unix, Architecture.X64)] [TestCase(PlatformID.Unix, Architecture.Arm64)] [TestCase(PlatformID.Win32NT, Architecture.X64)] @@ -58,6 +65,7 @@ public void ApplicationUsesTheExpectedDefaultLogDirectoryLocation(PlatformID pla } [Test] + [Order(1)] [TestCase(PlatformID.Unix, Architecture.X64)] [TestCase(PlatformID.Unix, Architecture.Arm64)] [TestCase(PlatformID.Win32NT, Architecture.X64)] @@ -78,6 +86,7 @@ public void ApplicationUsesTheExpectedLogDirectoryLocationWhenSpecifiedOnTheComm } [Test] + [Order(2)] [TestCase(PlatformID.Unix, Architecture.X64)] [TestCase(PlatformID.Unix, Architecture.Arm64)] [TestCase(PlatformID.Win32NT, Architecture.X64)] @@ -98,6 +107,7 @@ public void ApplicationUsesTheExpectedLogDirectoryLocationWhenSpecifiedInTheSupp } [Test] + [Order(3)] [TestCase(PlatformID.Unix, Architecture.X64)] [TestCase(PlatformID.Unix, Architecture.Arm64)] [TestCase(PlatformID.Win32NT, Architecture.X64)] @@ -117,6 +127,7 @@ public void ApplicationUsesTheExpectedDefaultPackagesDirectoryLocation(PlatformI } [Test] + [Order(4)] [TestCase(PlatformID.Unix, Architecture.X64)] [TestCase(PlatformID.Unix, Architecture.Arm64)] [TestCase(PlatformID.Win32NT, Architecture.X64)] @@ -137,6 +148,7 @@ public void ApplicationUsesTheExpectedPackagesDirectoryLocationWhenSpecifiedOnTh } [Test] + [Order(5)] [TestCase(PlatformID.Unix, Architecture.X64)] [TestCase(PlatformID.Unix, Architecture.Arm64)] [TestCase(PlatformID.Win32NT, Architecture.X64)] @@ -157,6 +169,7 @@ public void ApplicationUsesTheExpectedPackagesDirectoryLocationWhenSpecifiedInTh } [Test] + [Order(6)] [TestCase(PlatformID.Unix, Architecture.X64)] [TestCase(PlatformID.Unix, Architecture.Arm64)] [TestCase(PlatformID.Win32NT, Architecture.X64)] @@ -177,6 +190,7 @@ public void ApplicationUsesTheExpectedDefaultStateDirectoryLocation(PlatformID p } [Test] + [Order(7)] [TestCase(PlatformID.Unix, Architecture.X64)] [TestCase(PlatformID.Unix, Architecture.Arm64)] [TestCase(PlatformID.Win32NT, Architecture.X64)] @@ -197,6 +211,7 @@ public void ApplicationUsesTheExpectedStateDirectoryLocationWhenSpecifiedOnTheCo } [Test] + [Order(8)] [TestCase(PlatformID.Unix, Architecture.X64)] [TestCase(PlatformID.Unix, Architecture.Arm64)] [TestCase(PlatformID.Win32NT, Architecture.X64)] @@ -217,6 +232,70 @@ public void ApplicationUsesTheExpectedStateDirectoryLocationWhenSpecifiedInTheSu } [Test] + [Order(9)] + [TestCase(PlatformID.Unix, Architecture.X64)] + [TestCase(PlatformID.Unix, Architecture.Arm64)] + [TestCase(PlatformID.Win32NT, Architecture.X64)] + [TestCase(PlatformID.Win32NT, Architecture.Arm64)] + public void ApplicationUsesTheExpectedDefaultTempDirectoryLocation(PlatformID platform, Architecture architecture) + { + this.SetupTest(platform, architecture); + + PlatformSpecifics platformSpecifics = new PlatformSpecifics(platform, architecture); + string expectedDirectory = this.mockFixture.Combine(MockFixture.GetDirectory(typeof(CommandBaseTests), "temp")); + string defaultDirectory = platformSpecifics.TempDirectory; + + this.EvaluateDirectoryPathOverrides(platformSpecifics); + string actualDirectory = platformSpecifics.TempDirectory; + + Assert.AreEqual(expectedDirectory, defaultDirectory, "Default temp directory does not match expected."); + Assert.AreEqual(expectedDirectory, actualDirectory); + } + + [Test] + [Order(10)] + [TestCase(PlatformID.Unix, Architecture.X64)] + [TestCase(PlatformID.Unix, Architecture.Arm64)] + [TestCase(PlatformID.Win32NT, Architecture.X64)] + [TestCase(PlatformID.Win32NT, Architecture.Arm64)] + public void ApplicationUsesTheExpectedTempDirectoryLocationWhenSpecifiedOnTheCommandLine(PlatformID platform, Architecture architecture) + { + this.SetupTest(platform, architecture); + + // Setup: + // Property set when --temp-dir= is used on the command line. + string expectedDirectory = this.mockFixture.Combine(this.mockFixture.PlatformSpecifics.CurrentDirectory, "alternate_temp_1"); + this.TempDirectory = expectedDirectory; + + this.EvaluateDirectoryPathOverrides(this.mockFixture.PlatformSpecifics); + string actualDirectory = this.mockFixture.PlatformSpecifics.TempDirectory; + + Assert.AreEqual(expectedDirectory, actualDirectory); + } + + [Test] + [Order(11)] + [TestCase(PlatformID.Unix, Architecture.X64)] + [TestCase(PlatformID.Unix, Architecture.Arm64)] + [TestCase(PlatformID.Win32NT, Architecture.X64)] + [TestCase(PlatformID.Win32NT, Architecture.Arm64)] + public void ApplicationUsesTheExpectedTempDirectoryLocationWhenSpecifiedInTheSupportedEnvironmentVariable(PlatformID platform, Architecture architecture) + { + this.SetupTest(platform, architecture); + + // Setup: + // Environment variable 'VC_TEMP_DIR' can be used to define the logs directory. + string expectedDirectory = this.mockFixture.Combine(this.mockFixture.PlatformSpecifics.CurrentDirectory, "alternate_temp_2"); + this.mockFixture.SetEnvironmentVariable(EnvironmentVariable.VC_TEMP_DIR, expectedDirectory); + + this.EvaluateDirectoryPathOverrides(this.mockFixture.PlatformSpecifics); + string actualDirectory = this.mockFixture.PlatformSpecifics.TempDirectory; + + Assert.AreEqual(expectedDirectory, actualDirectory); + } + + [Test] + [Order(15)] [TestCase(PlatformID.Unix, Architecture.X64)] [TestCase(PlatformID.Unix, Architecture.Arm64)] [TestCase(PlatformID.Win32NT, Architecture.X64)] @@ -234,6 +313,7 @@ public void CommandBaseCanCreateLoggers(PlatformID platform, Architecture archit } [Test] + [Order(16)] public void CommandBaseCanCreateEventHubLoggers() { this.SetupTest(PlatformID.Unix, Architecture.X64); @@ -267,6 +347,7 @@ public void CommandBaseCanCreateEventHubLoggers() } [Test] + [Order(17)] public void CommandBaseCanCreateMultipleLoggers() { this.SetupTest(PlatformID.Unix, Architecture.X64); @@ -276,7 +357,6 @@ public void CommandBaseCanCreateMultipleLoggers() .ReturnsAsync(this.mockFixture.Create()); List loggerDefinitions = new List(); loggerDefinitions.Add("eventHub;sb://any.servicebus.windows.net/?cid=307591a4-abb2-4559-af59-b47177d140cf&tid=985bbc17-e3a5-4fec-b0cb-40dbb8bc5959&crtt=123456789"); - loggerDefinitions.Add(@"proxy;https://vc.com"); loggerDefinitions.Add("console"); loggerDefinitions.Add("csv"); loggerDefinitions.Add("file"); @@ -299,8 +379,8 @@ public void CommandBaseCanCreateMultipleLoggers() configuration.GetSection("EventHubLogSettings").Bind(eventHubLogSettings); IList loggers = testCommand.CreateLogger(configuration, this.mockFixture.PlatformSpecifics); - // 1 console, 3 serilog and 1 csv file logger, 3 eventhub, 1 proxy - Assert.AreEqual(loggers.Count, 9); + // 1 console, 3 serilog and 1 csv file logger, 3 eventhub + Assert.AreEqual(loggers.Count, 8); } /// diff --git a/src/VirtualClient/VirtualClient.UnitTests/CommandLineOptionTests.cs b/src/VirtualClient/VirtualClient.UnitTests/CommandLineOptionTests.cs index bb5daef493..e239f9d44d 100644 --- a/src/VirtualClient/VirtualClient.UnitTests/CommandLineOptionTests.cs +++ b/src/VirtualClient/VirtualClient.UnitTests/CommandLineOptionTests.cs @@ -170,12 +170,8 @@ public void VirtualClientThrowsWhenAnUnrecognizedOptionIsSuppliedOnTheCommandLin } [Test] - [TestCase("--agent-id", "AgentID")] [TestCase("--agentId", "AgentID")] - [TestCase("--agentid", "AgentID")] - [TestCase("--clientId", "AgentID")] - [TestCase("--clientid", "AgentID")] - [TestCase("--client", "AgentID")] + [TestCase("--client-id", "AgentID")] [TestCase("--c", "AgentID")] [TestCase("--port", "4501")] [TestCase("--api-port", "4501")] @@ -183,42 +179,26 @@ public void VirtualClientThrowsWhenAnUnrecognizedOptionIsSuppliedOnTheCommandLin [TestCase("--clean", "logs")] [TestCase("--clean", "logs,packages,state,temp")] [TestCase("--content-store", "https://anystorageaccount.blob.core.windows.net/;SharedAccessSignature=123")] - [TestCase("--contentStore", "https://anystorageaccount.blob.core.windows.net/;SharedAccessSignature=123")] - [TestCase("--contentstore", "https://anystorageaccount.blob.core.windows.net/;SharedAccessSignature=123")] [TestCase("--content", "https://anystorageaccount.blob.core.windows.net/;SharedAccessSignature=123")] [TestCase("--cs", "https://anystorageaccount.blob.core.windows.net/;SharedAccessSignature=123")] [TestCase("--content-path-template", "anyname1/anyname2/{experimentId}/{agentId}/anyname3/{toolName}/{role}/{scenario}")] - [TestCase("--contentPathTemplate", "anyname1/anyname2/{experimentId}/{agentId}/anyname3/{toolName}/{role}/{scenario}")] - [TestCase("--contentpathtemplate", "anyname1/anyname2/{experimentId}/{agentId}/anyname3/{toolName}/{role}/{scenario}")] - [TestCase("--contentPath", "anyname1/anyname2/{experimentId}/{agentId}/anyname3/{toolName}/{role}/{scenario}")] - [TestCase("--contentpath", "anyname1/anyname2/{experimentId}/{agentId}/anyname3/{toolName}/{role}/{scenario}")] [TestCase("--content-path", "anyname1/anyname2/{experimentId}/{agentId}/anyname3/{toolName}/{role}/{scenario}")] [TestCase("--cp", "anyname1/anyname2/{experimentId}/{agentId}/anyname3/{toolName}/{role}/{scenario}")] [TestCase("--dependencies", null)] [TestCase("--eventHubConnectionString", "Endpoint=ConnectionString")] - [TestCase("--eventhubconnectionstring", "Endpoint=ConnectionString")] [TestCase("--event-hub", "Endpoint=ConnectionString")] - [TestCase("--eventHub", "Endpoint=ConnectionString")] - [TestCase("--eventhub", "Endpoint=ConnectionString")] - [TestCase("--eh", "Endpoint=ConnectionString")] [TestCase("--experimentId", "0B692DEB-411E-4AC1-80D5-AF539AE1D6B2")] - [TestCase("--experimentid", "0B692DEB-411E-4AC1-80D5-AF539AE1D6B2")] [TestCase("--experiment-id", "0B692DEB-411E-4AC1-80D5-AF539AE1D6B2")] - [TestCase("--experiment", "0B692DEB-411E-4AC1-80D5-AF539AE1D6B2")] [TestCase("--e", "0B692DEB-411E-4AC1-80D5-AF539AE1D6B2")] [TestCase("--fail-fast", null)] [TestCase("--ff", null)] - [TestCase("--flush-wait", "00:10:00")] [TestCase("--exit-wait", "00:10:00")] [TestCase("--wait", "00:10:00")] - [TestCase("--isolated", null)] [TestCase("--i", "3")] [TestCase("--iterations", "3")] [TestCase("--kv", "https://anyvault.vault.windows.net")] [TestCase("--key-vault", "https://anyvault.vault.windows.net")] [TestCase("--layout-path", "C:\\any\\path\\to\\layout.json")] - [TestCase("--layoutPath", "C:\\any\\path\\to\\layout.json")] - [TestCase("--layoutpath", "C:\\any\\path\\to\\layout.json")] [TestCase("--layout", "C:\\any\\path\\to\\layout.json")] [TestCase("--lp", "C:\\any\\path\\to\\layout.json")] [TestCase("--logger", "file")] @@ -239,8 +219,6 @@ public void VirtualClientThrowsWhenAnUnrecognizedOptionIsSuppliedOnTheCommandLin [TestCase("--package-dir", "C:\\any\\path\\to\\packages")] [TestCase("--pdir", "C:\\any\\path\\to\\packages")] [TestCase("--package-store", "https://anystorageaccount.blob.core.windows.net/?sv=2020-08-04&ss=b")] - [TestCase("--packageStore", "https://anystorageaccount.blob.core.windows.net/?sv=2020-08-04&ss=b")] - [TestCase("--packagestore", "https://anystorageaccount.blob.core.windows.net/?sv=2020-08-04&ss=b")] [TestCase("--packages", "https://anystorageaccount.blob.core.windows.net/?sv=2020-08-04&ss=b")] [TestCase("--ps", "https://anystorageaccount.blob.core.windows.net/?sv=2020-08-04&ss=b")] [TestCase("--parameters", "Param1=Value1,,,Param2=Value2")] @@ -293,26 +271,16 @@ public void VirtualClientDefaultCommandSupportsAllExpectedOptions(string option, [Test] [TestCase("--agentId", "AgentID")] - [TestCase("--agentid", "AgentID")] - [TestCase("--agent-id", "AgentID")] - [TestCase("--clientId", "AgentID")] - [TestCase("--clientid", "AgentID")] [TestCase("--client-id", "AgentID")] - [TestCase("--client", "AgentID")] [TestCase("--c", "AgentID")] [TestCase("--clean", null)] [TestCase("--clean", "logs")] [TestCase("--clean", "logs,packages,state,temp")] [TestCase("--event-hub", "Endpoint=ConnectionString")] - [TestCase("--eventHub", "Endpoint=ConnectionString")] - [TestCase("--eventhub", "Endpoint=ConnectionString")] - [TestCase("--eh", "Endpoint=ConnectionString")] + [TestCase("--eventHubConnectionString", "Endpoint=ConnectionString")] [TestCase("--experimentId", "0B692DEB-411E-4AC1-80D5-AF539AE1D6B2")] - [TestCase("--experimentid", "0B692DEB-411E-4AC1-80D5-AF539AE1D6B2")] [TestCase("--experiment-id", "0B692DEB-411E-4AC1-80D5-AF539AE1D6B2")] - [TestCase("--experiment", "0B692DEB-411E-4AC1-80D5-AF539AE1D6B2")] [TestCase("--e", "0B692DEB-411E-4AC1-80D5-AF539AE1D6B2")] - [TestCase("--isolated", null)] [TestCase("--metadata", "Key1=Value1,,,Key2=Value2")] [TestCase("--mt", "Key1=Value1,,,Key2=Value2")] [TestCase("--n", "anypackage")] @@ -374,7 +342,7 @@ public void VirtualClientBootstrapCommandHandlesNoOpArguments() "--package", "anypackage.1.0.0.zip", "--package-store", "https://anystorageaccount.blob.core.windows.net/?sv=2020-08-04&ss=b", "--iterations", "1", - "--layoutPath", "/home/user/any/layout.json" + "--layout-path", "/home/user/any/layout.json" }; Assert.DoesNotThrow(() => diff --git a/src/VirtualClient/VirtualClient.UnitTests/ExecuteRemoteAgentCommandTests.cs b/src/VirtualClient/VirtualClient.UnitTests/ExecuteRemoteAgentCommandTests.cs new file mode 100644 index 0000000000..1df41de0d0 --- /dev/null +++ b/src/VirtualClient/VirtualClient.UnitTests/ExecuteRemoteAgentCommandTests.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.UnitTests +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Threading.Tasks; + using NUnit.Framework; + + [TestFixture] + [Category("Unit")] + public class ExecuteRemoteAgentCommandTests + { + [Test] + [TestCase( + "remote --profile=ANY-PROFILE.json --packages=anystore --timeout=01:00:00 --agent-ssh=user@10.1.2.3;pass", + "--profile=ANY-PROFILE.json --packages=anystore --timeout=01:00:00")] + [TestCase( + "remote --profile=ANY-PROFILE.json --packages=anystore --timeout=01:00:00 --agent-ssh=user@10.1.2.3;pass --agent-ssh=user@10.1.2.4;pass", + "--profile=ANY-PROFILE.json --packages=anystore --timeout=01:00:00")] + [TestCase( + "remote --profile=ANY-PROFILE.json --packages=anystore --timeout=01:00:00 --package-dir=/any/packages --log-dir=/any/logs --state-dir=/any/state --temp-dir=/any/temp --agent-ssh=user@10.1.2.3;pass --agent-ssh=user@10.1.2.4;pass", + "--profile=ANY-PROFILE.json --packages=anystore --timeout=01:00:00 --package-dir=/any/packages --log-dir=/any/logs --state-dir=/any/state --temp-dir=/any/temp")] + public void ExecuteRemoteAgentCommandSuppliesTheExpectedCommandToExecuteOnTheTargetAgents_1(string originalCommand, string expectedTargetCommand) + { + var command = new TestExecuteRemoteAgentCommand(); + string actualTargetCommand = command.GetTargetCommandArguments(originalCommand.Split(' ')); + Assert.AreEqual(expectedTargetCommand, actualTargetCommand); + } + + [Test] + [TestCase( + "remote \"./packages/custom_scripts.1.0.0/execute_workload.py --log-dir=/any/log/dir\" --packages=anystore --timeout=01:00:00 --agent-ssh=user@10.1.2.3;pass", + "\"./packages/custom_scripts.1.0.0/execute_workload.py --log-dir=/any/log/dir\" --packages=anystore --timeout=01:00:00")] + [TestCase( + "remote \"./packages/custom_scripts.1.0.0/execute_workload.py --log-dir=/any/log/dir\" --packages=anystore --timeout=01:00:00 --packages=anystore --timeout=01:00:00 --agent-ssh=user@10.1.2.3;pass --agent-ssh=user@10.1.2.4;pass", + "\"./packages/custom_scripts.1.0.0/execute_workload.py --log-dir=/any/log/dir\" --packages=anystore --timeout=01:00:00 --packages=anystore --timeout=01:00:00")] + [TestCase( + "remote \"./packages/custom_scripts.1.0.0/execute_workload.py --log-dir=/any/log/dir\" --packages=anystore --timeout=01:00:00 --packages=anystore --timeout=01:00:00 --package-dir=/any/packages --log-dir=/any/logs --state-dir=/any/state --temp-dir=/any/temp --agent-ssh=user@10.1.2.3;pass --agent-ssh=user@10.1.2.4;pass", + "\"./packages/custom_scripts.1.0.0/execute_workload.py --log-dir=/any/log/dir\" --packages=anystore --timeout=01:00:00 --packages=anystore --timeout=01:00:00 --package-dir=/any/packages --log-dir=/any/logs --state-dir=/any/state --temp-dir=/any/temp")] + public void ExecuteRemoteAgentCommandSuppliesTheExpectedCommandToExecuteOnTheTargetAgents_2(string originalCommand, string expectedTargetCommand) + { + var command = new TestExecuteRemoteAgentCommand(); + string actualTargetCommand = command.GetTargetCommandArguments(originalCommand.Split(' ')); + Assert.AreEqual(expectedTargetCommand, actualTargetCommand); + } + + private class TestExecuteRemoteAgentCommand : ExecuteRemoteAgentCommand + { + public new string GetTargetCommandArguments(string[] commandArguments) + { + return base.GetTargetCommandArguments(commandArguments); + } + } + } +} diff --git a/src/VirtualClient/VirtualClient.UnitTests/OptionFactoryTests.cs b/src/VirtualClient/VirtualClient.UnitTests/OptionFactoryTests.cs index d91e470897..96059775a8 100644 --- a/src/VirtualClient/VirtualClient.UnitTests/OptionFactoryTests.cs +++ b/src/VirtualClient/VirtualClient.UnitTests/OptionFactoryTests.cs @@ -30,6 +30,106 @@ public void SetupFixture() Environment.CurrentDirectory = MockFixture.GetDirectory(typeof(OptionFactoryTests)); } + [Test] + [TestCase("--profile=ANY-PROFILE.json --e=1234 --timeout=00:01:00")] + [TestCase("--profile=ANY-PROFILE.json --e 1234 --timeout=00:01:00")] + [TestCase("--profile=ANY-PROFILE.json --experiment-id=1234 --timeout=00:01:00")] + [TestCase("--profile=ANY-PROFILE.json --experiment-id 1234 --timeout=00:01:00")] + public void ContainsOptionCorrectlyIdentifiesWhenAnOptionExistsInTheCommandLine(string commandLine) + { + string[] args = commandLine.Split(" "); + Option option = OptionFactory.CreateExperimentIdOption(); + Assert.IsTrue(OptionFactory.ContainsOption(option, args)); + } + + [Test] + [TestCase("--profile=ANY-PROFILE.json --timeout=00:01:00")] + [TestCase("--profile=ANY-PROFILE.json --timeout=00:01:00")] + [TestCase("--profile=ANY-PROFILE.json --timeout=00:01:00")] + [TestCase("--profile=ANY-PROFILE.json --timeout=00:01:00")] + public void ContainsOptionCorrectlyIdentifiesWhenAnOptionDoesNotExistInTheCommandLine(string commandLine) + { + string[] args = commandLine.Split(" "); + Option option = OptionFactory.CreateExperimentIdOption(); + Assert.IsFalse(OptionFactory.ContainsOption(option, args)); + } + + [Test] + [TestCase("--profile=ANY-PROFILE.json --ex=do_not_confuse --timeout=00:01:00")] + [TestCase("--profile=ANY-PROFILE.json --extra do_not_confuse --timeout=00:01:00")] + [TestCase("--profile=ANY-PROFILE.json --experiment-id-other=do_not_confuse --timeout=00:01:00")] + [TestCase("--profile=ANY-PROFILE.json --experiment-id-other do_not_confuse --timeout=00:01:00")] + public void ContainsOptionDoesNotMistakeOtherOptionsWithVerySimilarNames(string commandLine) + { + string[] args = commandLine.Split(" "); + Option option = OptionFactory.CreateExperimentIdOption(); + Assert.IsFalse(OptionFactory.ContainsOption(option, args)); + } + + [Test] + [TestCase(null)] + [TestCase(" ")] + [TestCase(" ")] + public void ContainsOptionHandlesEmptyCommandLines(string commandLine) + { + Option option = OptionFactory.CreateExperimentIdOption(); + Assert.IsFalse(OptionFactory.ContainsOption(option, commandLine)); + } + + [Test] + [TestCase("--profile=ANY-PROFILE.json --e=1234 --timeout=00:01:00")] + [TestCase("--profile=ANY-PROFILE.json --e 1234 --timeout=00:01:00")] + [TestCase("--profile=ANY-PROFILE.json --experiment-id=1234 --timeout=00:01:00")] + [TestCase("--profile=ANY-PROFILE.json --experiment-id 1234 --timeout=00:01:00")] + public void ContainsOptionHandlesFullCommandLineEvaluations_1(string commandLine) + { + Option option = OptionFactory.CreateExperimentIdOption(); + Assert.IsTrue(OptionFactory.ContainsOption(option, commandLine)); + } + + [Test] + [TestCase("--profile=ANY-PROFILE.json --ex=do_not_confuse --timeout=00:01:00")] + [TestCase("--profile=ANY-PROFILE.json --extra do_not_confuse --timeout=00:01:00")] + [TestCase("--profile=ANY-PROFILE.json --experiment-id-other=do_not_confuse --timeout=00:01:00")] + [TestCase("--profile=ANY-PROFILE.json --experiment-id-other do_not_confuse --timeout=00:01:00")] + public void ContainsOptionHandlesFullCommandLineEvaluations_2(string commandLine) + { + Option option = OptionFactory.CreateExperimentIdOption(); + Assert.IsFalse(OptionFactory.ContainsOption(option, commandLine)); + } + + [Test] + [TestCase("--profile=ANY-PROFILE.json --timeout=00:01:00 --ff")] + [TestCase("--profile=ANY-PROFILE.json --timeout=00:01:00 --fail-fast")] + public void ContainsOptionCorrectlyIdentifiesWhenAFlagExistsInTheCommandLine(string commandLine) + { + string[] args = commandLine.Split(" "); + Option option = OptionFactory.CreateFailFastFlag(); + Assert.IsTrue(OptionFactory.ContainsOption(option, args)); + } + + [Test] + [TestCase("--profile=ANY-PROFILE.json --timeout=00:01:00")] + [TestCase("--profile=ANY-PROFILE.json --timeout=00:01:00")] + public void ContainsOptionCorrectlyIdentifiesWhenAFlagDoesNotExistInTheCommandLine(string commandLine) + { + string[] args = commandLine.Split(" "); + Option option = OptionFactory.CreateFailFastFlag(); + Assert.IsFalse(OptionFactory.ContainsOption(option, args)); + } + + [Test] + [TestCase("--profile=ANY-PROFILE.json --timeout=00:01:00 --fff")] + [TestCase("--profile=ANY-PROFILE.json --timeout=00:01:00 --ff-other")] + [TestCase("--profile=ANY-PROFILE.json --timeout=00:01:00 --fail-forward")] + [TestCase("--profile=ANY-PROFILE.json --timeout=00:01:00 --fail-fast-flagrantly")] + public void ContainsOptionDoesNotMistakeOtherFlagsWithVerySimilarNames(string commandLine) + { + string[] args = commandLine.Split(" "); + Option option = OptionFactory.CreateFailFastFlag(); + Assert.IsFalse(OptionFactory.ContainsOption(option, args)); + } + [Test] [TestCase("--port")] [TestCase("--api-port")] @@ -105,17 +205,21 @@ public void CleanOptionSupportsExpectedTargetResources(string alias) Assert.IsFalse(result.Errors.Any()); Assert.AreEqual("state", result.Tokens[1].Value); + result = option.Parse($"{alias}=temp"); + Assert.IsFalse(result.Errors.Any()); + Assert.AreEqual("temp", result.Tokens[1].Value); + result = option.Parse($"{alias}=all"); Assert.IsFalse(result.Errors.Any()); Assert.AreEqual("all", result.Tokens[1].Value); - result = option.Parse($"{alias}=logs,packages,state"); + result = option.Parse($"{alias}=logs,packages,state,temp"); Assert.IsFalse(result.Errors.Any()); - Assert.AreEqual("logs,packages,state", result.Tokens[1].Value); + Assert.AreEqual("logs,packages,state,temp", result.Tokens[1].Value); - result = option.Parse($"{alias}=logs;packages;state"); + result = option.Parse($"{alias}=logs;packages;state;temp"); Assert.IsFalse(result.Errors.Any()); - Assert.AreEqual("logs;packages;state", result.Tokens[1].Value); + Assert.AreEqual("logs;packages;state;temp", result.Tokens[1].Value); } [Test] @@ -127,13 +231,8 @@ public void CleanOptionValidatesTheTargetsProvided(string alias) } [Test] - [TestCase("--agent-id")] [TestCase("--agentId")] - [TestCase("--agentid")] [TestCase("--client-id")] - [TestCase("--clientId")] - [TestCase("--clientid")] - [TestCase("--client")] [TestCase("--c")] public void ClientIdOptionSupportsExpectedAliases(string alias) { @@ -144,8 +243,6 @@ public void ClientIdOptionSupportsExpectedAliases(string alias) [Test] [TestCase("--content-store")] - [TestCase("--contentStore")] - [TestCase("--contentstore")] [TestCase("--content")] [TestCase("--cs")] public void ContentStoreOptionSupportsExpectedAliases(string alias) @@ -229,7 +326,7 @@ public void ContentStoreOptionSupportsConnectionStringsWithMicrosoftEntraIdAndCe .ReturnsAsync(OptionFactoryTests.GenerateMockCertificate()); Option option = OptionFactory.CreateContentStoreOption(certificateManager: mockCertManager.Object); - ParseResult result = option.Parse($"--contentStore={argument}"); + ParseResult result = option.Parse($"--content-store={argument}"); Assert.IsFalse(result.Errors.Any()); } @@ -257,7 +354,7 @@ public void ContentStoreOptionSupportsUrisWithMicrosoftEntraIdAndCertificateRefe .ReturnsAsync(OptionFactoryTests.GenerateMockCertificate()); Option option = OptionFactory.CreateContentStoreOption(certificateManager: mockCertManager.Object); - ParseResult result = option.Parse($"--contentStore={argument}"); + ParseResult result = option.Parse($"--content-store={argument}"); Assert.IsFalse(result.Errors.Any()); } @@ -265,16 +362,12 @@ public void ContentStoreOptionSupportsUrisWithMicrosoftEntraIdAndCertificateRefe public void ContentStoreOptionValidatesTheConnectionTokenProvided() { Option option = OptionFactory.CreateContentStoreOption(); - Assert.Throws(() => option.Parse($"--contentStore=NotAValidConnectionStringOrSasTokenUri")); + Assert.Throws(() => option.Parse($"--content-store=NotAValidConnectionStringOrSasTokenUri")); } [Test] [TestCase("--content-path-template")] [TestCase("--content-path")] - [TestCase("--contentPathTemplate")] - [TestCase("--contentpathtemplate")] - [TestCase("--contentPath")] - [TestCase("--contentpath")] [TestCase("--cp")] public void ContentPathTemplateOptionSupportsExpectedAliases(string alias) { @@ -353,11 +446,7 @@ public void DataSchemaOptionThrowsOnAnInvalidValue() [Test] [TestCase("--event-hub")] - [TestCase("--eventHub")] - [TestCase("--eventhub")] - [TestCase("--eventhubconnectionstring")] [TestCase("--eventHubConnectionString")] - [TestCase("--eh")] public void EventHubConnectionStringOptionSupportsExpectedAliases(string alias) { Option option = OptionFactory.CreateEventHubStoreOption(); @@ -395,8 +484,6 @@ public void EventHubConnectionStringOptionSupportsUrisWithManagedIdentityReferen [Test] [TestCase("--experiment-id")] [TestCase("--experimentId")] - [TestCase("--experimentid")] - [TestCase("--experiment")] [TestCase("--e")] public void ExperimentIdOptionSupportsExpectedAliases(string alias) { @@ -408,7 +495,6 @@ public void ExperimentIdOptionSupportsExpectedAliases(string alias) [Test] [TestCase("--exit-wait")] - [TestCase("--flush-wait")] [TestCase("--wait")] public void ExitWaitOptionSupportsExpectedAliases(string alias) { @@ -542,19 +628,8 @@ public void KeyVaultOptionSupportsUrisWithMicrosoftEntraIdAndCertificateReferenc Assert.IsFalse(result.Errors.Any()); } - [Test] - [TestCase("--isolated")] - public void IsolatedFlagSupportsExpectedAliases(string alias) - { - Option option = OptionFactory.CreateIsolatedFlag(); - ParseResult result = option.Parse(alias); - Assert.IsFalse(result.Errors.Any()); - } - [Test] [TestCase("--layout-path")] - [TestCase("--layoutPath")] - [TestCase("--layoutpath")] [TestCase("--layout")] [TestCase("--lp")] public void LayoutPathOptionSupportsExpectedAliases(string alias) @@ -845,8 +920,6 @@ public void PackageDirectoryOptionSupportsRelativePathsInDefaultValues(string pa [Test] [TestCase("--package-store")] - [TestCase("--packageStore")] - [TestCase("--packagestore")] [TestCase("--packages")] [TestCase("--ps")] public void PackageStoreOptionSupportsExpectedAliases(string alias) @@ -1125,11 +1198,11 @@ public void ProxyApiOptionsCannotBeUsedAtTheSameTimeWithTheEventHubConnectionStr { Option option = OptionFactory.CreateProxyApiOption(); - CommandLineBuilder commandBuilder = Program.SetupCommandLine(new string[] { "--eventHub=sb://any.servicebus.hub?miid=1234567", "--proxy-api=http://anyuri" }, tokenSource); - Assert.Throws(() => commandBuilder.Build().Parse("--eventHub=sb://any.servicebus.hub?miid=1234567 --proxy-api=http://anyuri")); + CommandLineBuilder commandBuilder = Program.SetupCommandLine(new string[] { "--event-hub=sb://any.servicebus.hub?miid=1234567", "--proxy-api=http://anyuri" }, tokenSource); + Assert.Throws(() => commandBuilder.Build().Parse("--event-hub=sb://any.servicebus.hub?miid=1234567 --proxy-api=http://anyuri")); - commandBuilder = Program.SetupCommandLine(new string[] { "--proxy-api=http://anyuri", "--eventHub=sb://any.servicebus.hub?miid=1234567" }, tokenSource); - Assert.Throws(() => commandBuilder.Build().Parse("--proxy-api=http://anyuri --eventHub=sb://any.servicebus.hub?miid=1234567")); + commandBuilder = Program.SetupCommandLine(new string[] { "--proxy-api=http://anyuri", "--event-hub=sb://any.servicebus.hub?miid=1234567" }, tokenSource); + Assert.Throws(() => commandBuilder.Build().Parse("--proxy-api=http://anyuri --event-hub=sb://any.servicebus.hub?miid=1234567")); } } @@ -1239,6 +1312,55 @@ public void SystemOptionSupportsExpectedAliases(string alias) Assert.IsFalse(result.Errors.Any()); } + [Test] + [TestCase("user@10.2.3.5;pass")] + [TestCase("user@machine_name;pass")] + [TestCase("user@2001:0db8:85a3:0000:0000:8a2e:0370:7334;pass")] + [TestCase("user@2001:db8:85a3:0:0:8a2e:370:7334;pass")] + [TestCase("user@2001:db8:85a3::8a2e:370:7334;pass")] + public void TargetAgentOptionSupportsExpectedSshConnectionValues(string value) + { + Option option = OptionFactory.CreateTargetAgentOption(); + ParseResult result = option.Parse($"--agent-ssh={value}"); + Assert.IsFalse(result.Errors.Any()); + } + + [Test] + [TestCase("user@10.2.3.5;pass_;w0r;d")] + [TestCase("user@10.2.3.5;pass__w@rd")] + [TestCase("user@machine@somewhere;pass")] + [TestCase("user@machine@somewhere;pass;_w@rd")] + [TestCase("user@2001:db8:85a3:0:0:8a2e:370:7334;pass;_w@rd")] + public void TargetAgentOptionHandlesSshConnectionsContainingDelimitersInTrickyLocations(string value) + { + Option option = OptionFactory.CreateTargetAgentOption(); + ParseResult result = option.Parse($"--agent-ssh={value}"); + Assert.IsFalse(result.Errors.Any()); + } + + [Test] + [TestCase("user@")] + [TestCase("@10.2.3.4;pass")] + [TestCase("user@10.2.3.4")] + [TestCase("user;pass")] + [TestCase("user;10.2.3.4;pass")] + public void TargetAgentOptionValidatesSshConnectionFormats(string invalidValue) + { + Option option = OptionFactory.CreateTargetAgentOption(); + NotSupportedException error = Assert.Throws(() => option.Parse($"--agent-ssh={invalidValue}")); + Assert.IsTrue(error.Message.StartsWith("Invalid target agent SSH definition.")); + } + + [Test] + [TestCase("--ssh")] + [TestCase("--agent-ssh")] + public void TargetAgentOptionSupportsExpectedAliases(string alias) + { + Option option = OptionFactory.CreateTargetAgentOption(); + ParseResult result = option.Parse($"{alias}=user@10.2.3.4;pass"); + Assert.IsFalse(result.Errors.Any()); + } + [Test] [TestCase("--directory")] public void TargetDirectoryOptionSupportsExpectedAliases(string alias) diff --git a/src/VirtualClient/VirtualClient.sln b/src/VirtualClient/VirtualClient.sln index 3aed437f31..88d174ef07 100644 --- a/src/VirtualClient/VirtualClient.sln +++ b/src/VirtualClient/VirtualClient.sln @@ -67,6 +67,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VirtualClient.IntegrationTe EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VirtualClient.Core.FunctionalTests", "VirtualClient.Core.FunctionalTests\VirtualClient.Core.FunctionalTests.csproj", "{2B672960-0AB0-493A-BB34-DF7AFCF2E507}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VirtualClient.Controller", "VirtualClient.Controller\VirtualClient.Controller.csproj", "{9C8CF12C-82BB-C733-495E-F3DAC50614F6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VirtualClient.Controller.UnitTests", "VirtualClient.Controller.UnitTests\VirtualClient.Controller.UnitTests.csproj", "{B23B27E8-E245-7173-C624-68ABAACF95B9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -383,6 +387,30 @@ Global {2B672960-0AB0-493A-BB34-DF7AFCF2E507}.Release|ARM64.Build.0 = Release|ARM64 {2B672960-0AB0-493A-BB34-DF7AFCF2E507}.Release|x64.ActiveCfg = Release|x64 {2B672960-0AB0-493A-BB34-DF7AFCF2E507}.Release|x64.Build.0 = Release|x64 + {9C8CF12C-82BB-C733-495E-F3DAC50614F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9C8CF12C-82BB-C733-495E-F3DAC50614F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9C8CF12C-82BB-C733-495E-F3DAC50614F6}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {9C8CF12C-82BB-C733-495E-F3DAC50614F6}.Debug|ARM64.Build.0 = Debug|ARM64 + {9C8CF12C-82BB-C733-495E-F3DAC50614F6}.Debug|x64.ActiveCfg = Debug|x64 + {9C8CF12C-82BB-C733-495E-F3DAC50614F6}.Debug|x64.Build.0 = Debug|x64 + {9C8CF12C-82BB-C733-495E-F3DAC50614F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9C8CF12C-82BB-C733-495E-F3DAC50614F6}.Release|Any CPU.Build.0 = Release|Any CPU + {9C8CF12C-82BB-C733-495E-F3DAC50614F6}.Release|ARM64.ActiveCfg = Release|ARM64 + {9C8CF12C-82BB-C733-495E-F3DAC50614F6}.Release|ARM64.Build.0 = Release|ARM64 + {9C8CF12C-82BB-C733-495E-F3DAC50614F6}.Release|x64.ActiveCfg = Release|x64 + {9C8CF12C-82BB-C733-495E-F3DAC50614F6}.Release|x64.Build.0 = Release|x64 + {B23B27E8-E245-7173-C624-68ABAACF95B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B23B27E8-E245-7173-C624-68ABAACF95B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B23B27E8-E245-7173-C624-68ABAACF95B9}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {B23B27E8-E245-7173-C624-68ABAACF95B9}.Debug|ARM64.Build.0 = Debug|ARM64 + {B23B27E8-E245-7173-C624-68ABAACF95B9}.Debug|x64.ActiveCfg = Debug|x64 + {B23B27E8-E245-7173-C624-68ABAACF95B9}.Debug|x64.Build.0 = Debug|x64 + {B23B27E8-E245-7173-C624-68ABAACF95B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B23B27E8-E245-7173-C624-68ABAACF95B9}.Release|Any CPU.Build.0 = Release|Any CPU + {B23B27E8-E245-7173-C624-68ABAACF95B9}.Release|ARM64.ActiveCfg = Release|ARM64 + {B23B27E8-E245-7173-C624-68ABAACF95B9}.Release|ARM64.Build.0 = Release|ARM64 + {B23B27E8-E245-7173-C624-68ABAACF95B9}.Release|x64.ActiveCfg = Release|x64 + {B23B27E8-E245-7173-C624-68ABAACF95B9}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/website/docs/guides/0010-command-line.md b/website/docs/guides/0010-command-line.md index 85fd98fb26..03f54a0602 100644 --- a/website/docs/guides/0010-command-line.md +++ b/website/docs/guides/0010-command-line.md @@ -9,14 +9,15 @@ on the system. |----------------------------------------------------------------|----------|------------------------------|-------------| | --p, --profile=\ | Yes | string/text | The execution profile which indicates the set of workloads to run. | | --ps, --packages, --package-store=\ | No | string/connection string/SAS | A full connection description for an [Azure Storage Account](./0600-integration-blob-storage.md) from which to download workload and dependency packages. This is required for most workloads because the workload binary/script packages are not typically packaged with the Virtual Client application itself. This option defaults to a public storage account VC team maintains.

The following are supported identifiers for this option:
  • Storage Account blob service SAS URIs
  • Storage Account blob container SAS URIs
  • Microsoft Entra ID/Apps using a certificate
  • Microsoft Azure managed identities
See [Azure Storage Account Integration](./0600-integration-blob-storage.md) for additional details on supported identifiers.

Always surround connection descriptions with quotation marks. | -| --c, --client, --client-id=\ | No | string/text | An identifier that can be used to uniquely identify the instance of the Virtual Client in telemetry separate from other instances. The default value is the name of the system if this option is not explicitly defined (i.e. the name as defined by the operating system). | +| --c, --client-id=\ | No | string/text | An identifier that can be used to uniquely identify the instance of the Virtual Client in telemetry separate from other instances. The default value is the name of the system if this option is not explicitly defined (i.e. the name as defined by the operating system). | | --port, --api-port=\ | No | integer | The port to use for hosting the Virtual Client REST API service for profiles that allow multi-system, client/server operations (e.g. networking). Additionally, a port may be defined for each role associated with the profile operations using the format \{Port}/\{Role} with each port/role combination delimited by a comma (e.g. 4501/Client,4502/Server). | -| --clean=\ | No | string | Instructs the application to perform an initial clean before continuing to remove pre-existing files/content created by the application from the file system. This can include log files, packages previously downloaded and state management files. This option can be used as a flag (e.g. --clean) as well to clean all file content. Valid target resources include: logs, packages, state, all (e.g. --clean=logs, --clean=packages). Multiple resources can be comma-delimited (e.g. --clean=logs,packages). To perform a full reset of the application state, use the option as a flag (e.g. --clean). This effectively sets the application back to a "first run" state. | +| --clean=\ | No | string | Instructs the application to perform an initial clean before continuing to remove pre-existing files/content created by the application from the file system. This can include log files, packages previously downloaded, state management and temporary files. This option can be used as a flag (e.g. --clean) as well to clean all file content. Valid target resources include: logs, packages, state, temp, all (e.g. --clean=logs, --clean=packages). Multiple resources can be comma-delimited (e.g. --clean=logs,packages). To perform a full reset of the application state, use the option as a flag (e.g. --clean). This effectively sets the application back to a "first run" state. | | --cs, --content, --content-store=\ | No | string/connection string/SAS | A full connection description for an [Azure Storage Account](./0600-integration-blob-storage.md) to use for uploading files/content (e.g. log files).

The following are supported identifiers for this option:
  • Storage Account blob service SAS URIs
  • Storage Account blob container SAS URIs
  • Microsoft Entra ID/Apps using a certificate
  • Microsoft Azure managed identities
See [Azure Storage Account Integration](./0600-integration-blob-storage.md) for additional details on supported identifiers.

Always surround connection descriptions with quotation marks. | | --cp, --content-path, --content-path-template=\| No | string/text | The content path format/structure to use when uploading content to target storage resources. When not defined the 'Default' structure is used. Default: "\{experimentId}/\{agentId}/\{toolName}/\{role}/\{scenario}" | -| --eh, --eventhub, --event-hub=\ | No | string/connection string | A full connection description for an [Azure Event Hub namespace](./0610-integration-event-hub.md) to send/upload telemetry data from the operations of the Virtual Client.

The following are supported identifiers for this option:
  • Event Hub namespace shared access policies
  • Microsoft Entra ID/Apps using a certificate
  • Microsoft Azure managed identities
See [Azure Event Hub Integration](./0610-integration-event-hub.md) for additional details on supported identifiers.

Always surround connection descriptions with quotation marks.

Note that this option will be deprecated in future releases. Use "--logger=eventhub;\{connection\}" going forward. | -| --e, --experiment, --experiment-id=\ | No | guid | A unique identifier that defines the ID of the experiment for which the Virtual Client workload is associated. | +| --event-hub=\ | No | string/connection string | A full connection description for an [Azure Event Hub namespace](./0610-integration-event-hub.md) to send/upload telemetry data from the operations of the Virtual Client.

The following are supported identifiers for this option:
  • Event Hub namespace shared access policies
  • Microsoft Entra ID/Apps using a certificate
  • Microsoft Azure managed identities
See [Azure Event Hub Integration](./0610-integration-event-hub.md) for additional details on supported identifiers.

Always surround connection descriptions with quotation marks.

Note that this option will be deprecated in future releases. Use "--logger=eventhub;\{connection\}" going forward. | +| --e, --experiment-id=\ | No | guid | A unique identifier that defines the ID of the experiment for which the Virtual Client workload is associated. | | --ff, --fail-fast | No | | Flag indicates that the application should exit immediately on first/any errors regardless of their severity. This applies to 'Actions' in the profile only. 'Dependencies' are ALWAYS implemented to fail fast. 'Monitors' are generally implemented to handle transient issues and to keep running/trying in the background. | +| --isolated | No | | Flag indicates that the application should run with dependency isolation in place. This will result in a unique directory (per experiment ID) used for logs, packages, state and temp file storage. | | --kv, --keyvault, --key-vault=\ | No | uri string/connection string | A full connection description for an [Azure Key Vault](./0620-integration-key-vault.md) to use for referencing secrets and certificates from azure keyvault.

The following are supported identifiers for this option:
  • Microsoft Entra ID/Apps using a certificate
  • Microsoft Azure managed identities
See [Azure Key Vault Integration](./0620-integration-key-vault.md) for additional details on supported identifiers.

Always surround connection descriptions with quotation marks. | | --lp, --layout, --layout-path=\ | No | string/path | A path to a environment layout file that provides additional metadata about the system/hardware on which the Virtual Client will run and information required to support client/server advanced topologies. See [Client/Server Support](./0020-client-server.md). | | --logger=\ | No | string/path | One or more logger definitions. Multiple loggers/options can be used on the command line (e.g. --logger=logger1 --logger=logger2). | @@ -31,9 +32,10 @@ on the system. | --sd, --seed=\ | No | integer | The seed used to guarantee identical randomization between executions. | | --sdir, --state-dir=\ | No | string/path | Defines an alternate directory to which state files/documents will be written. | | --s, --system=\ | No | string/text | The execution system/platform in which Virtual Client is running (e.g. Azure). | +| --tdir, --temp-dir=\ | No | string/path | Defines an alternate directory to which temp files/documents will be written. | | --t, --timeout=\,deterministic
--timeout=\,deterministic\* | No | timespan or integer | Specifies a timespan or the length of time (in minutes) that the Virtual Client should run before timing out and exiting (e.g. 1440, 01.00:00:00). The user can additionally provide an extra instruction to indicate the application should wait for deterministic completions.

Use --timeout=-1 or --timeout=never to indicate run forever.

Use the '**deterministic**' instruction to indicate the application should wait for the current action/workload to complete before timing out (e.g. --timeout=1440,deterministic).

Use the '**deterministic***' instruction to indicate the application should wait for all actions/workloads in the profile to complete before timing out (e.g. --timeout=1440,deterministic*).

Note that this option cannot be used with the `--iterations` option.

If neither the `--timeout` nor `--iterations` option are supplied, the Virtual Client will run one iteration. | | --i, --iterations=\ | No | integer | Defines the number of iterations/rounds of all actions in the profile to execute before exiting.

Note that this option cannot be used with the `--timeout` option.

If neither the `--timeout` nor `--iterations` option are supplied, the Virtual Client will run one iteration. | -| --wait, --exit-wait, --flush-wait=\ | No | timespan or integer | Specifies a timespan or the length of time (in minutes) that the Virtual Client should wait for workload and monitor processes to complete and for telemetry to be fully flushed before exiting (e.g. 60, 01:00:00). This is useful for scenarios where Event Hub resources are used to ensure that all telemetry is uploaded successfully before exit. Default = 30 mins. | +| --wait, --exit-wait=\ | No | timespan or integer | Specifies a timespan or the length of time (in minutes) that the Virtual Client should wait for workload and monitor processes to complete and for telemetry to be fully flushed before exiting (e.g. 60, 01:00:00). This is useful for scenarios where Event Hub resources are used to ensure that all telemetry is uploaded successfully before exit. Default = 30 mins. | | --dependencies | No | | Flag indicates that only the dependencies defined in the profile should be executed/installed. | | --verbose | No | | Request verbose logging output to the console. This is equivalent to setting `--log-level=Trace` | | -?, -h, --help | No | | Show help information. | @@ -58,13 +60,15 @@ VirtualClient.exe --package-store="https://anypackagestorage.blob.core.windows.net?cid=...&tid=..." --package-dir="C:\Users\User\Packages" --state-dir="C:\Users\User\State" + --temp-dir="C:\Users\User\Temp" --metadata="Group=Group A,,,Intent=Performance Baseline,,,Specification=2025.08.01H" --parameters="Duration=00:05:00" --scenarios="SHA1,SHA256,SHA512" --clean=logs,state --exit-wait=00:10:00 - --logger="file" - --logger="SummaryFileLoggerProvider;C:\Users\User\Logs" + --logger=csv + --logger=file + --logger=summary --logger="eventhub;sb://anynamespace.servicebus.windows.net?cid=...&tid=..." --log-dir="C:\Users\User\Logs" --log-level=Warning @@ -86,13 +90,15 @@ VirtualClient.exe --package-store="https://anypackagestorage.blob.core.windows.net?cid=...&tid=..." --package-dir="C:\Users\User\Packages" --state-dir="C:\Users\User\State" + --temp-dir="C:\Users\User\Temp" --metadata="Group=Group A,,,Intent=Performance Baseline,,,Specification=2025.08.01H" --parameters="Duration=00:05:00" --clean=logs,state --exit-wait=00:10:00 --layout-path="C:\Users\User\VirtualClient\layout.json" - --logger="file" - --logger="SummaryFileLoggerProvider;C:\Users\User\Logs" + --logger=csv + --logger=file + --logger="summary;C:\Users\User\Logs\{experimentId}-summary.log" --logger="eventhub;sb://anynamespace.servicebus.windows.net?cid=...&tid=..." --log-dir="C:\Users\User\Logs" --log-retention=01.00:00:00 @@ -110,21 +116,20 @@ VirtualClient.exe --content-path-template="{experimentId}/{agentId}/{toolName}" --content-store="https://anycontentstorage.blob.core.windows.net?cid=...&tid=..." --package-store="https://anypackagestorage.blob.core.windows.net?cid=...&tid=..." - --package-dir="C:\Users\User\Packages" - --state-dir="C:\Users\User\State" --metadata="Group=Group A,,,Intent=Performance Baseline,,,Specification=2025.08.01H" --parameters="Duration=00:05:00" --clean=logs,state --exit-wait=00:10:00 --layout-path="C:\Users\User\VirtualClient\layout.json" - --logger="file" - --logger="SummaryFileLoggerProvider;C:\Users\User\Logs" + --logger=csv + --logger"file + --logger="CustomLoggerProvider;C:\Users\User\Logs\{experimentId}-custom.log" --logger="eventhub;sb://anynamespace.servicebus.windows.net?cid=...&tid=..." - --log-dir="C:\Users\User\Logs" --log-retention=01.00:00:00 --system=Demo --fail-fast --log-to-file + --isolated --verbose ``` @@ -141,10 +146,11 @@ The following tables describe the various subcommands that are supported by the |---------------------------------------------------------|----------|------------------------------|-------------| | --pkg, --package =\ | Yes | string/blob name | Defines the name/ID of a package to bootstrap/install (e.g. anypackage.1.0.0.zip). | | --ps, --packages, --package-store=\ | Yes | string/connection string/SAS | A full connection description for an [Azure Storage Account](./0600-integration-blob-storage.md) from which to download workload and dependency packages. This is required for most workloads because the workload binary/script packages are not typically packaged with the Virtual Client application itself.

The following are supported identifiers for this option:
  • Storage Account blob service SAS URIs
  • Storage Account blob container SAS URIs
  • Microsoft Entra ID/Apps using a certificate
  • Microsoft Azure managed identities
See [Azure Storage Account Integration](./0600-integration-blob-storage.md) for additional details on supported identifiers.

Always surround connection descriptions with quotation marks. | - | --c, --client, --client-id=\ | No | string/text | An identifier that can be used to uniquely identify the instance of the Virtual Client in telemetry separate from other instances. The default value is the name of the system if this option is not explicitly defined (i.e. the name as defined by the operating system). | - | --clean=\ | No | string | Instructs the application to perform an initial clean before continuing to remove pre-existing files/content created by the application from the file system. This can include log files, packages previously downloaded and state management files. This option can be used as a flag (e.g. --clean) as well to clean all file content. Valid target resources include: logs, packages, state, all (e.g. --clean=logs, --clean=packages). Multiple resources can be comma-delimited (e.g. --clean=logs,packages). To perform a full reset of the application state, use the option as a flag (e.g. --clean). This effectively sets the application back to a "first run" state. | - | --eh, --eventhub, --event-hub=\ | No | string/connection string | A full connection description for an [Azure Event Hub namespace](./0610-integration-event-hub.md) to send/upload telemetry data from the operations of the Virtual Client.

The following are supported identifiers for this option:
  • Event Hub namespace shared access policies
  • Microsoft Entra ID/Apps using a certificate
  • Microsoft Azure managed identities
See [Azure Event Hub Integration](./0610-integration-event-hub.md) for additional details on supported identifiers.

Always surround connection descriptions with quotation marks.

Note that this option will be deprecated in future releases. Use "--logger=eventhub;\{connection\}" going forward. | - | --e, --experiment, --experiment-id=\ | No | guid | A unique identifier that defines the ID of the experiment for which the Virtual Client workload is associated. | + | --c, --client-id=\ | No | string/text | An identifier that can be used to uniquely identify the instance of the Virtual Client in telemetry separate from other instances. The default value is the name of the system if this option is not explicitly defined (i.e. the name as defined by the operating system). | + | --clean=\ | No | string | Instructs the application to perform an initial clean before continuing to remove pre-existing files/content created by the application from the file system. This can include log files, packages previously downloaded, state management and temporary files. This option can be used as a flag (e.g. --clean) as well to clean all file content. Valid target resources include: logs, packages, state, temp, all (e.g. --clean=logs, --clean=packages). Multiple resources can be comma-delimited (e.g. --clean=logs,packages). To perform a full reset of the application state, use the option as a flag (e.g. --clean). This effectively sets the application back to a "first run" state. | + | --event-hub=\ | No | string/connection string | A full connection description for an [Azure Event Hub namespace](./0610-integration-event-hub.md) to send/upload telemetry data from the operations of the Virtual Client.

The following are supported identifiers for this option:
  • Event Hub namespace shared access policies
  • Microsoft Entra ID/Apps using a certificate
  • Microsoft Azure managed identities
See [Azure Event Hub Integration](./0610-integration-event-hub.md) for additional details on supported identifiers.

Always surround connection descriptions with quotation marks.

Note that this option will be deprecated in future releases. Use "--logger=eventhub;\{connection\}" going forward. | + | --e, --experiment-id=\ | No | guid | A unique identifier that defines the ID of the experiment for which the Virtual Client workload is associated. | + | --isolated | No | | Flag indicates that the application should run with dependency isolation in place. This will result in a unique directory (per experiment ID) used for logs, packages, state and temp file storage. | | --logger=\ | No | string/path | One or more logger definitions. Multiple loggers/options can be used on the command line (e.g. --logger=logger1 --logger=logger2). | | --ldir, --log-dir=\ | No | string/path | Defines an alternate directory to which log files should be written. | | --ll, --log-level | No | integer/string | Defines the logging severity level for traces output. Values map to the [Microsoft.Extensions.Logging.LogLevel](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel?view=dotnet-plat-ext-8.0) enumeration. Valid values include: Trace (0), Debug (1), Information (2), Warning (3), Error (4), Critical (5). Note that this option affects ONLY trace logs and is designed to allow the user to control the amount of operational telemetry emitted by VC. It does not affect metrics or event logging nor any non-telemetry logging. Default = Information (2). | @@ -154,7 +160,8 @@ The following tables describe the various subcommands that are supported by the | --pdir, --package-dir=\ | No | string/path | Defines an alternate directory to which packages will be downloaded. | | --sdir, --state-dir=\ | No | string/path | Defines an alternate directory to which state files/documents will be written. | | --s, --system=\ | No | string/text | The execution system/platform in which Virtual Client is running (e.g. Azure). | - | --wait, --exit-wait, --flush-wait=\ | No | timespan or integer | Specifies a timespan or the length of time (in minutes) that the Virtual Client should wait for workload and monitor processes to complete and for telemetry to be fully flushed before exiting (e.g. 60, 01:00:00). This is useful for scenarios where Event Hub resources are used to ensure that all telemetry is uploaded successfully before exit. Default = 30 mins. | + | --tdir, --temp-dir=\ | No | string/path | Defines an alternate directory to which temp files/documents will be written. | + | --wait, --exit-wait=\ | No | timespan or integer | Specifies a timespan or the length of time (in minutes) that the Virtual Client should wait for workload and monitor processes to complete and for telemetry to be fully flushed before exiting (e.g. 60, 01:00:00). This is useful for scenarios where Event Hub resources are used to ensure that all telemetry is uploaded successfully before exit. Default = 30 mins. | | --verbose | No | | Request verbose logging output to the console. This is equivalent to setting `--log-level=Trace` | | -?, -h, --help | No | | Show help information. | | --version | No | | Show application version information. | @@ -176,8 +183,9 @@ The following tables describe the various subcommands that are supported by the --metadata="Group=Group A,,,Intent=Performance Baseline,,,Specification=2025.08.01H" --clean=logs --exit-wait=00:10:00 - --logger="file" - --logger="SummaryFileLoggerProvider;C:\Users\User\Logs" + --logger=csv + --logger=file + --logger=summary --logger="eventhub;sb://anynamespace.servicebus.windows.net?cid=...&tid=..." --log-dir="C:\Users\User\Logs" --log-level=Information @@ -191,11 +199,14 @@ The following tables describe the various subcommands that are supported by the | Option | Required | Data Type | Description | |------------------------------------------------|----------|----------------------|-------------| - | --clean=\ | No | string | Instructs the application to perform an initial clean before continuing to remove pre-existing files/content created by the application from the file system. This can include log files, packages previously downloaded and state management files. This option can be used as a flag (e.g. --clean) as well to clean all file content. Valid target resources include: logs, packages, state, all (e.g. --clean=logs, --clean=packages). Multiple resources can be comma-delimited (e.g. --clean=logs,packages). To perform a full reset of the application state, use the option as a flag (e.g. --clean). This effectively sets the application back to a "first run" state. | + | --clean=\ | No | string | Instructs the application to perform an initial clean before continuing to remove pre-existing files/content created by the application from the file system. This can include log files, packages previously downloaded, state management and temporary files. This option can be used as a flag (e.g. --clean) as well to clean all file content. Valid target resources include: logs, packages, state, temp, all (e.g. --clean=logs, --clean=packages). Multiple resources can be comma-delimited (e.g. --clean=logs,packages). To perform a full reset of the application state, use the option as a flag (e.g. --clean). This effectively sets the application back to a "first run" state. | | --logger=\ | No | string/path | One or more logger definitions. Multiple loggers/options can be used on the command line (e.g. --logger=logger1 --logger=logger2). | - | --ldir, --log-dir=\ | No | string/path | Defines an alternate directory to which log files should be written. | + | --ldir, --log-dir=\ | No | string/path | Defines an alternate directory containing log files to be deleted. | | --ll, --log-level | No | integer/string | Defines the logging severity level for traces output. Values map to the [Microsoft.Extensions.Logging.LogLevel](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel?view=dotnet-plat-ext-8.0) enumeration. Valid values include: Trace (0), Debug (1), Information (2), Warning (3), Error (4), Critical (5). Note that this option affects ONLY trace logs and is designed to allow the user to control the amount of operational telemetry emitted by VC. It does not affect metrics or event logging nor any non-telemetry logging. Default = Information (2). | | --lr, --log-retention=\ | No | timespan or integer | Defines the log retention period. This is a timespan or length of time (in minutes) to apply to cleaning up/deleting existing log files (e.g. 2880, 02.00:00:00). Log files with creation times older than the retention period will be deleted. | + | --pdir, --package-dir=\ | No | string/path | Defines an alternate directory containing packages to be deleted. | + | --sdir, --state-dir=\ | No | string/path | Defines an alternate directory containing state files/documents to be deleted. | + | --tdir, --temp-dir=\ | No | string/path | Defines an alternate directory containing temp files/documents to be deleted. | | -?, -h, --help | No | | Show help information. | | --version | No | | Show application version information. | @@ -232,14 +243,15 @@ The following tables describe the various subcommands that are supported by the VirtualClient.exe convert --profile=S:\Users\Any\Profiles\PERF-CPU-OPENSSL.yml --output-path=S:\Users\Any\Profiles ``` -* ### runapi +* ### api Runs the Virtual Client API service and optionally monitors the API (local or a remote instance) for heartbeats. | Option | Required | Data Type | Description | |--------------------------------------------|----------|---------------------|-------------| - | --clean=\ | No | string | Instructs the application to perform an initial clean before continuing to remove pre-existing files/content created by the application from the file system. This can include log files, packages previously downloaded and state management files. This option can be used as a flag (e.g. --clean) as well to clean all file content. Valid target resources include: logs, packages, state, all (e.g. --clean=logs, --clean=packages). Multiple resources can be comma-delimited (e.g. --clean=logs,packages). To perform a full reset of the application state, use the option as a flag (e.g. --clean). This effectively sets the application back to a "first run" state. | + | --clean=\ | No | string | Instructs the application to perform an initial clean before continuing to remove pre-existing files/content created by the application from the file system. This can include log files, packages previously downloaded, state management and temporary files. This option can be used as a flag (e.g. --clean) as well to clean all file content. Valid target resources include: logs, packages, state, temp, all (e.g. --clean=logs, --clean=packages). Multiple resources can be comma-delimited (e.g. --clean=logs,packages). To perform a full reset of the application state, use the option as a flag (e.g. --clean). This effectively sets the application back to a "first run" state. | | --port, --api-port=\ | No | integer | The port to use for hosting the Virtual Client REST API service. Additionally, a port may be defined for the Client system and Server system independently using the format `\ / \` with each port/role combination delimited by a comma (e.g. 4501/Client,4502/Server). | | --ip, --ip-address | No | string/IP address | An IPv4 or IPv6 address of a target/remote system on which a Virtual Client instance is running to monitor. The API service must also be running on the target instance. | + | --isolated | No | | Flag indicates that the application should run with dependency isolation in place. This will result in a unique directory (per experiment ID) used for logs, packages, state and temp file storage. | | --logger=\ | No | string/path | One or more logger definitions. Multiple loggers/options can be used on the command line (e.g. --logger=logger1 --logger=logger2). | | --ldir, --log-dir=\ | No | string/path | Defines an alternate directory to which log files should be written. | | --ll, --log-level | No | integer/string | Defines the logging severity level for traces output. Values map to the [Microsoft.Extensions.Logging.LogLevel](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel?view=dotnet-plat-ext-8.0) enumeration. Valid values include: Trace (0), Debug (1), Information (2), Warning (3), Error (4), Critical (5). Note that this option affects ONLY trace logs and is designed to allow the user to control the amount of operational telemetry emitted by VC. It does not affect metrics or event logging nor any non-telemetry logging. Default = Information (2). | @@ -251,10 +263,10 @@ The following tables describe the various subcommands that are supported by the ``` bash # Run the API service locally. - VirtualClient.exe runapi + VirtualClient.exe api # Run the API service locally and monitor another remote instance of the Virtual Client. - VirtualClient.exe runapi --monitor --ip-address=1.2.3.4 + VirtualClient.exe api --monitor --ip-address=1.2.3.4 ``` * ### upload-telemetry @@ -270,10 +282,10 @@ The following tables describe the various subcommands that are supported by the | --logger=\ | Yes | string/path | One or more logger definitions each providing the ability to upload telemetry to a target internet endpoint (e.g. Event Hub). Multiple loggers/options can be used on the command line (e.g. --logger=logger1 --logger=logger2). | | --directory=\ | Conditional | string/path | A directory to search for telemetry data point files to process. Note that one of either the ```--files``` or ```--directory``` option must be supplied. | | --files=\ | Conditional | string/path | A comma-delimited list of telemetry data point files to process. Note that one of either the ```--files``` or ```--directory``` option must be supplied. | - | --clean=\ | No | string | Instructs the application to perform an initial clean before continuing to remove pre-existing files/content created by the application from the file system. This can include log files, packages previously downloaded and state management files. This option can be used as a flag (e.g. --clean) as well to clean all file content. Valid target resources include: logs, packages, state, all (e.g. --clean=logs, --clean=packages). Multiple resources can be comma-delimited (e.g. --clean=logs,packages). To perform a full reset of the application state, use the option as a flag (e.g. --clean). This effectively sets the application back to a "first run" state. | - | --c, --client, --client-id=\ | No | string/text | An identifier that can be used to uniquely identify the instance of the Virtual Client in telemetry separate from other instances. The default value is the name of the system if this option is not explicitly defined (i.e. the name as defined by the operating system). | - | --eh, --eventhub, --event-hub=\ | No | string/connection string | A full connection description for an [Azure Event Hub namespace](./0610-integration-event-hub.md) to send/upload telemetry data from the operations of the Virtual Client.

The following are supported identifiers for this option:
  • Event Hub namespace shared access policies
  • Microsoft Entra ID/Apps using a certificate
  • Microsoft Azure managed identities
See [Azure Event Hub Integration](./0610-integration-event-hub.md) for additional details on supported identifiers.

Always surround connection descriptions with quotation marks.

Note that this option will be deprecated in future releases. Use "--logger=eventhub;\{connection\}" going forward. | - | --e, --experiment, --experiment-id=\ | No | guid | A unique identifier that defines the ID of the experiment for which the Virtual Client workload is associated. | + | --clean=\ | No | string | Instructs the application to perform an initial clean before continuing to remove pre-existing files/content created by the application from the file system. This can include log files, packages previously downloaded, state management and temporary files. This option can be used as a flag (e.g. --clean) as well to clean all file content. Valid target resources include: logs, packages, state, all (e.g. --clean=logs, --clean=packages). Multiple resources can be comma-delimited (e.g. --clean=logs,packages). To perform a full reset of the application state, use the option as a flag (e.g. --clean). This effectively sets the application back to a "first run" state. | + | --c, --client-id=\ | No | string/text | An identifier that can be used to uniquely identify the instance of the Virtual Client in telemetry separate from other instances. The default value is the name of the system if this option is not explicitly defined (i.e. the name as defined by the operating system). | + | --event-hub=\ | No | string/connection string | A full connection description for an [Azure Event Hub namespace](./0610-integration-event-hub.md) to send/upload telemetry data from the operations of the Virtual Client.

The following are supported identifiers for this option:
  • Event Hub namespace shared access policies
  • Microsoft Entra ID/Apps using a certificate
  • Microsoft Azure managed identities
See [Azure Event Hub Integration](./0610-integration-event-hub.md) for additional details on supported identifiers.

Always surround connection descriptions with quotation marks.

Note that this option will be deprecated in future releases. Use "--logger=eventhub;\{connection\}" going forward. | + | --e, --experiment-id=\ | No | guid | A unique identifier that defines the ID of the experiment for which the Virtual Client workload is associated. | | --ldir, --log-dir=\ | No | string/path | Defines an alternate directory to which log files should be written. | | --ll, --log-level | No | integer/string | Defines the logging severity level for traces output. Values map to the [Microsoft.Extensions.Logging.LogLevel](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel?view=dotnet-plat-ext-8.0) enumeration. Valid values include: Trace (0), Debug (1), Information (2), Warning (3), Error (4), Critical (5). Note that this option affects ONLY trace logs and is designed to allow the user to control the amount of operational telemetry emitted by VC. It does not affect metrics or event logging nor any non-telemetry logging. Default = Information (2). | | --lr, --log-retention=\ | No | timespan or integer | Defines the log retention period. This is a timespan or length of time (in minutes) to apply to cleaning up/deleting existing log files (e.g. 2880, 02.00:00:00). Log files with creation times older than the retention period will be deleted. | @@ -282,7 +294,7 @@ The following tables describe the various subcommands that are supported by the | --mt, --metadata=\ | No | string/text | Metadata to include with all telemetry data points uploaded to the target store. Each metadata entry should be a key/value pair separated by ",,," delimiters or traditional delimiters such as a comma "," or a semi-colon ";".

e.g.
  • --metadata="property1=value1,,,property2=value2"
  • --metadata="property1=value1,property2=value2"
  • --metadata="property1=value1;property2=value2"
It is recommended to avoid mixing different delimiters together. Always surround metadata values with quotation marks. | | --recursive | No | | Flag instructs a recursive search of the target directory (i.e. --directory) when searching for telemetry data point files to process. Default = false (top directory only). | | --s, --system=\ | No | string/text | The execution system/platform in which Virtual Client is running (e.g. Azure). | - | --wait, --exit-wait, --flush-wait=\ | No | timespan or integer | Specifies a timespan or the length of time (in minutes) that the Virtual Client should wait for workload and monitor processes to complete and for telemetry to be fully flushed before exiting (e.g. 60, 01:00:00). This is useful for scenarios where Event Hub resources are used to ensure that all telemetry is uploaded successfully before exit. Default = 30 mins. | + | --wait, --exit-wait=\ | No | timespan or integer | Specifies a timespan or the length of time (in minutes) that the Virtual Client should wait for workload and monitor processes to complete and for telemetry to be fully flushed before exiting (e.g. 60, 01:00:00). This is useful for scenarios where Event Hub resources are used to ensure that all telemetry is uploaded successfully before exit. Default = 30 mins. | | --verbose | No | | Request verbose logging output to the console. This is equivalent to setting `--log-level=Trace` | | -?, -h, --help | No | | Show help information. | | --version | No | | Show application version information. | diff --git a/website/docs/guides/0200-usage-examples.md b/website/docs/guides/0200-usage-examples.md index cf48cf9dc4..6e47c11bfe 100644 --- a/website/docs/guides/0200-usage-examples.md +++ b/website/docs/guides/0200-usage-examples.md @@ -86,24 +86,24 @@ user would want to cleanup the local state files. The following examples show ho # Perform a full clean. This will remove ALL log files/directories, any packages previously downloaded minus # those that are "built-in" or part of the Virtual Client package itself and any state files previously written. # This essentially resets Virtual Client back to the state it was in before the first run on the system. -./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{BlobStoreConnectionString|SAS URI}" --clean -./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{BlobStoreConnectionString|SAS URI}" --clean=all +./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{ConnectionString|SAS URI}" --clean +./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{ConnectionString|SAS URI}" --clean=all # Clean specific target resources. -./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{BlobStoreConnectionString|SAS URI}" --clean=logs -./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{BlobStoreConnectionString|SAS URI}" --clean=packages -./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{BlobStoreConnectionString|SAS URI}" --clean=state +./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{ConnectionString|SAS URI}" --clean=logs +./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{ConnectionString|SAS URI}" --clean=packages +./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{ConnectionString|SAS URI}" --clean=state # Clean multiple specific target resources all together. -./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{BlobStoreConnectionString|SAS URI}" --clean=logs,state -./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{BlobStoreConnectionString|SAS URI}" --clean=logs,packages,state +./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{ConnectionString|SAS URI}" --clean=logs,state +./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{ConnectionString|SAS URI}" --clean=logs,packages,state # Apply a log retention period to the log files. This will cause log files older than the period to # be removed but will preserve any remaining. Note that this is the same as --clean=logs --log-retention=02.00:00:00. -./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{BlobStoreConnectionString|SAS URI}" --log-retention=02.00:00:00 +./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{ConnectionString|SAS URI}" --log-retention=02.00:00:00 # Log retentions can be in 'minutes' as well (e.g. 2800 minutes = 2 days). Note that this is the same as --clean=logs --log-retention=2880. -./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{BlobStoreConnectionString|SAS URI}" --log-retention=2880 +./VirtualClient --profile=PERF-CPU-COREMARK.json --timeout=180 --packages="{ConnectionString|SAS URI}" --log-retention=2880 ``` ## Scenario: Upload Metrics and Logs to an Event Hub @@ -115,7 +115,7 @@ Note that the Virtual Client does have a set of explicit expectations for how th ``` bash # To send data to an Event Hub, supply a connection string to the Event Hub namespace on the command line. -VirtualClient.exe --profile=PERF-CPU-OPENSSL.json --timeout=180 --packages="{BlobStoreConnectionString|SAS URI}" --event-hub="{EventHubConnectionString}" +VirtualClient.exe --profile=PERF-CPU-OPENSSL.json --timeout=180 --packages="{BlobStoreConnectionString|SAS URI}" --logger="eventhub;{ConnectionString|SAS URI}" ``` ## Scenario: Upload Log Files to a Content Store @@ -127,7 +127,7 @@ on monitor profiles below for additional details on which profiles support this. * [Monitor Profiles](../monitors/0200-monitor-profiles.md) ``` bash -VirtualClient.exe --profile=PERF-NETWORK.json --timeout=180 --packages="{BlobStoreConnectionString|SAS URI}" --content="{BlobStoreConnectionString|SAS URI}" --parameters=ProfilingEnabled=true,,,ProfilingMode=Interval +VirtualClient.exe --profile=PERF-NETWORK.json --timeout=180 --packages="{ConnectionString|SAS URI}" --content="{ConnectionString|SAS URI}" --parameters=ProfilingEnabled=true,,,ProfilingMode=Interval ``` ## Scenario: Change the Amount of Operational Trace Telemetry Emitted @@ -136,26 +136,26 @@ amount of information is not desirable. The logging level (or severity) can be c ``` bash # Emit traces for 'Warning' level and above only. -VirtualClient.exe --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{BlobStoreConnectionString|SAS URI}" --log-level=Warning +VirtualClient.exe --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{ConnectionString|SAS URI}" --log-level=Warning # Emit traces for 'Error' level and above only. -VirtualClient.exe --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{BlobStoreConnectionString|SAS URI}" --log-level=Error +VirtualClient.exe --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{ConnectionString|SAS URI}" --log-level=Error # Emit traces for 'Critical' level and above only. -VirtualClient.exe --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{BlobStoreConnectionString|SAS URI}" --log-level=Critical +VirtualClient.exe --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{ConnectionString|SAS URI}" --log-level=Critical ``` Correspondingly, there are times when more operational traces are desirable (e.g. for debugging scenarios). The default logging level is 'Information'. ``` bash # Emit all traces (...the most verbose option) -VirtualClient.exe --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{BlobStoreConnectionString|SAS URI}" --log-level=Trace +VirtualClient.exe --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{ConnectionString|SAS URI}" --log-level=Trace # Emit traces for 'Debug' level and above. -VirtualClient.exe --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{BlobStoreConnectionString|SAS URI}" --log-level=Debug +VirtualClient.exe --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{ConnectionString|SAS URI}" --log-level=Debug # Emit traces for 'Information' level and above only. -VirtualClient.exe --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{BlobStoreConnectionString|SAS URI}" --log-level=Information +VirtualClient.exe --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{ConnectionString|SAS URI}" --log-level=Information ``` ## Scenario: Change the Default Location for Log Files @@ -165,11 +165,11 @@ available. ``` bash # Define an alternate location for log files on the command line. -/home/user/virtualclient$ VirtualClient --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{BlobStoreConnectionString|SAS URI}" --log-dir="/home/user/logs" +/home/user/virtualclient$ VirtualClient --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{ConnectionString|SAS URI}" --log-dir="/home/user/logs" # Define an alternate location for log files using an environment variable. /home/user/virtualclient$ export VC_LOGS_DIR="/home/user/logs" -/home/user/virtualclient$ VirtualClient --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{BlobStoreConnectionString|SAS URI}" +/home/user/virtualclient$ VirtualClient --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{ConnectionString|SAS URI}" ``` ## Scenario: Change the Default Location for Package Downloads @@ -179,11 +179,11 @@ available. ``` bash # Define an alternate location for package downloads on the command line. -/home/user/virtualclient$ VirtualClient --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{BlobStoreConnectionString|SAS URI}" --package-dir="/home/user/packages" +/home/user/virtualclient$ VirtualClient --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{ConnectionString|SAS URI}" --package-dir="/home/user/packages" # Define an alternate location for package downloads using an environment variable. /home/user/virtualclient$ export VC_PACKAGES_DIR="/home/user/packages" -/home/user/virtualclient$ VirtualClient --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{BlobStoreConnectionString|SAS URI}" +/home/user/virtualclient$ VirtualClient --profile=PERF-CPU-OPENSSL.json --timeout=03:00:00 --packages="{ConnectionString|SAS URI}" ``` ## Supported Environment Variables @@ -269,4 +269,20 @@ can be used to define alternate locations for dependencies: # Note that the state directory can be defined on the command line as well. State directories # defined on the command line take priority over those defined by the environment variable. /home/user/virtualclient$ ./VirtualClient --profile=PERF-CPU-OPENSSL.json --timeout=120 --state-dir="/home/user/state" + ``` + +* **VC_TEMP_DIR** + Defines an alternate directory path to which Virtual Client should write temp files/documents. This overrides the default temp location + (e.g. \/temp). + + ``` bash + # On Windows systems + C:\VirtualClient> set VC_TEMP_DIR=C:\Users\User1\Temp + + # On Linux systems. + /home/user/virtualclient$ export VC_TEMP_DIR="/home/user/temp" + + # Note that the temp directory can be defined on the command line as well. Temp directories + # defined on the command line take priority over those defined by the environment variable. + /home/user/virtualclient$ ./VirtualClient --profile=PERF-CPU-OPENSSL.json --timeout=120 --temp-dir="/home/user/temp" ``` \ No newline at end of file