diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CreateResponseFileTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CreateResponseFileTests.cs new file mode 100644 index 0000000000..b8fe0df190 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CreateResponseFileTests.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Dependencies.UnitTests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.IO.Abstractions; + using System.Linq; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestPlatform.ObjectModel; + using Moq; + using NUnit.Framework; + using VirtualClient; + using VirtualClient.Common.Extensions; + using VirtualClient.Common.Telemetry; + + [TestFixture] + [Category("Unit")] + public class CreateResponseFileTests + { + private string tempDirectory; + private MockFixture mockFixture; + private IFileSystem fileSystem; + private Mock fileStreamMock; + + [SetUp] + public void SetUp() + { + this.tempDirectory = Path.Combine(Path.GetTempPath(), "VirtualClient", "UnitTests", Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(this.tempDirectory); + + Environment.CurrentDirectory = this.tempDirectory; + + this.mockFixture = new MockFixture(); + this.mockFixture.SetupMocks(true); + this.fileSystem = this.mockFixture.Dependencies.GetService(); + this.fileStreamMock = new Mock(); + } + + [Test] + public async Task ExecuteAsyncDoesNotCreateFileWhenNoOptionsAreSupplied() + { + this.mockFixture.Parameters = new Dictionary + { + ["FileName"] = "resource_access.rsp" + }; + + var executor = new TestCreateResponseFile(this.mockFixture); + + await executor.ExecuteAsync().ConfigureAwait(false); + + string expectedPath = Path.Combine(this.tempDirectory, "resource_access.rsp"); + Assert.False(this.fileSystem.File.Exists(expectedPath), "The response file should not be created when no options are supplied."); + + this.mockFixture.FileSystem.Verify(x => x.File.Delete(It.IsAny()), Times.Never); + } + + [Test] + [TestCase("")] + [TestCase(" ")] + [TestCase(null)] + [TestCase("C:\\repos\\VirtualClient\\out\\bin\\Debug\\x64\\VirtualClient.Main\\net9.0\\test.rsp")] + [TestCase("/home/vmadmin/VirtualClient/out/bin/debug/x64/VirtualClient.Main/net9.0/test.rsp")] + [TestCase("test.rsp")] + [TestCase("test.txt")] + public async Task ExecuteAsyncCreatesResponseFileAsExpected(string inputFilePath) + { + string expectedFilePath = string.IsNullOrWhiteSpace(inputFilePath) + ? "resource_access.rsp" + : inputFilePath; + + this.mockFixture.Parameters["FileName"] = inputFilePath; + this.mockFixture.Parameters["Option2"] = "--KeyVaultUri=\"https://testing123-vault.vault.azure.net\""; + this.mockFixture.Parameters["Option1"] = "--System=\"Testing\""; + + string expectedContent = string.Join(Environment.NewLine, this.mockFixture.Parameters + .Where(p => p.Key.StartsWith("Option", StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Value.ToString().Trim()) + .ToArray()); + + var executor = new TestCreateResponseFile(this.mockFixture); + + Mock mockFileStream = new Mock(); + this.mockFixture.FileStream.Setup(f => f.New(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(mockFileStream.Object) + .Callback((string path, FileMode mode, FileAccess access, FileShare share) => + { + Assert.AreEqual(expectedFilePath, path); + Assert.IsTrue(mode == FileMode.Create); + Assert.IsTrue(access == FileAccess.ReadWrite); + Assert.IsTrue(share == FileShare.ReadWrite); + }); + + await executor.ExecuteAsync().ConfigureAwait(false); + byte[] bytes = Encoding.UTF8.GetBytes(expectedContent); + mockFileStream.Verify(x => x.WriteAsync(It.Is>(x => (x.Length == bytes.Length)), It.IsAny()), Times.Exactly(1)); + } + + private class TestCreateResponseFile : CreateResponseFile + { + public TestCreateResponseFile(MockFixture mockFixture) + : base(mockFixture.Dependencies, mockFixture.Parameters) + { + } + + public Task ExecuteAsync() + { + return this.ExecuteAsync(EventContext.None, CancellationToken.None); + } + } + } +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Dependencies/CreateResponseFile.cs b/src/VirtualClient/VirtualClient.Dependencies/CreateResponseFile.cs new file mode 100644 index 0000000000..979b61d5c9 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Dependencies/CreateResponseFile.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Dependencies +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.IO.Abstractions; + using System.Linq; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using VirtualClient.Common.Extensions; + using VirtualClient.Common.Telemetry; + using VirtualClient.Contracts; + + /// + /// A Virtual Client component that creates a response file (e.g. a *.rsp file) containing a space-delimited + /// list of command-line options. + /// + /// + /// Virtual Client can automatically consume response files, allowing users to pass fewer arguments directly on the + /// command line (and keep long/complex option sets in a file instead). + /// + /// Options are provided via parameters whose keys start with Option (case-insensitive), for example: + /// Option1=--System="Testing" + /// Option2=--KeyVaultUri="https://testing123-vault.vault.azure.net" + /// + /// The file is written to unless is an absolute path. + /// Doc: https://natemcmaster.github.io/CommandLineUtils/docs/response-file-parsing.html?tabs=using-attributes + /// + public class CreateResponseFile : VirtualClientComponent + { + private readonly IFileSystem fileSystem; + + /// + /// Initializes a new instance of the class. + /// + /// Dependency injection container. + /// Component parameters. + public CreateResponseFile(IServiceCollection dependencies, IDictionary parameters) + : base(dependencies, parameters) + { + this.fileSystem = dependencies.GetService(); + this.fileSystem.ThrowIfNull(nameof(this.fileSystem)); + } + + /// + /// Gets the name (or path) of the response file to create. + /// Defaults to `resource_access.rsp`. + /// + public string FileName + { + get + { + string value = this.Parameters.GetValue(nameof(this.FileName), string.Empty); + return string.IsNullOrWhiteSpace(value) ? "resource_access.rsp" : value; + } + } + + /// + /// Creates the response file when one or more `Option*` parameters are supplied. + /// + /// Context information provided to telemetry events. + /// Token that can be used to cancel the operation. + protected override async Task ExecuteAsync(EventContext telemetryContext, CancellationToken cancellationToken) + { + string a = this.FileName; + + cancellationToken.ThrowIfCancellationRequested(); + + string[] optionValues = this.Parameters + .Where(p => p.Key.StartsWith("Option", StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Value.ToString().Trim()) + .ToArray(); + + telemetryContext.AddContext(nameof(optionValues), optionValues); + + if (optionValues.Length > 0) + { + if (this.fileSystem.File.Exists(this.FileName)) + { + this.fileSystem.File.Delete(this.FileName); + } + + string content = string.Join(Environment.NewLine, optionValues); + telemetryContext.AddContext(nameof(content), content); + + byte[] bytes = Encoding.UTF8.GetBytes(content); + + await using (FileSystemStream fileStream = this.fileSystem.FileStream.New( + this.FileName, + FileMode.Create, + FileAccess.ReadWrite, + FileShare.ReadWrite)) + { + await fileStream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + await fileStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + } + } +} \ No newline at end of file