diff --git a/VERSION b/VERSION index 36169be77c..7ab0fb4b2b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.55 \ No newline at end of file +2.1.56 \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Actions.FunctionalTests/GetAccessTokenProfileTests.cs b/src/VirtualClient/VirtualClient.Actions.FunctionalTests/GetAccessTokenProfileTests.cs new file mode 100644 index 0000000000..13b099c53a --- /dev/null +++ b/src/VirtualClient/VirtualClient.Actions.FunctionalTests/GetAccessTokenProfileTests.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Actions +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using Moq; + using NUnit.Framework; + using VirtualClient.Common; + using VirtualClient.Contracts; + + [TestFixture] + [Category("Functional")] + public class GetAccessTokenProfileTests + { + private DependencyFixture dependencyFixture; + + [OneTimeSetUp] + public void SetupFixture() + { + this.dependencyFixture = new DependencyFixture(); + ComponentTypeCache.Instance.LoadComponentTypes(TestDependencies.TestDirectory); + } + + [Test] + [TestCase("GET-ACCESS-TOKEN.json", PlatformID.Unix)] + [TestCase("GET-ACCESS-TOKEN.json", PlatformID.Win32NT)] + public void GetAccessTokenProfileParametersAreInlinedCorrectly(string profile, PlatformID platform) + { + this.dependencyFixture.Setup(platform); + using (ProfileExecutor executor = TestDependencies.CreateProfileExecutor(profile, this.dependencyFixture.Dependencies)) + { + WorkloadAssert.ParameterReferencesInlined(executor.Profile); + } + } + + [Test] + [TestCase("GET-ACCESS-TOKEN.json", PlatformID.Unix)] + [TestCase("GET-ACCESS-TOKEN.json", PlatformID.Win32NT)] + public void GetAccessTokenProfileParametersAreAvailable(string profile, PlatformID platform) + { + this.dependencyFixture.Setup(platform); + + var mandatoryParameters = new List { "KeyVaultUri", "TenantId" }; + using (ProfileExecutor executor = TestDependencies.CreateProfileExecutor(profile, this.dependencyFixture.Dependencies)) + { + Assert.IsEmpty(executor.Profile.Actions); + Assert.AreEqual(1, executor.Profile.Dependencies.Count); + + var dependencyBlock = executor.Profile.Dependencies.FirstOrDefault(); + + foreach (var parameters in mandatoryParameters) + { + Assert.IsTrue(dependencyBlock.Parameters.ContainsKey(parameters)); + } + } + } + } +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Contracts/DependencyKeyVaultStore.cs b/src/VirtualClient/VirtualClient.Contracts/DependencyKeyVaultStore.cs index 3f20d96445..959431724d 100644 --- a/src/VirtualClient/VirtualClient.Contracts/DependencyKeyVaultStore.cs +++ b/src/VirtualClient/VirtualClient.Contracts/DependencyKeyVaultStore.cs @@ -53,4 +53,4 @@ public DependencyKeyVaultStore(string storeName, Uri endpointUri, TokenCredentia /// public TokenCredential Credentials { get; } } -} +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Core.UnitTests/EndpointUtilityTests.cs b/src/VirtualClient/VirtualClient.Core.UnitTests/EndpointUtilityTests.cs index 960f157e7d..323ec33648 100644 --- a/src/VirtualClient/VirtualClient.Core.UnitTests/EndpointUtilityTests.cs +++ b/src/VirtualClient/VirtualClient.Core.UnitTests/EndpointUtilityTests.cs @@ -188,7 +188,7 @@ public void EndpointUtilityThrowsWhenCreatingBlobStoreReferenceForCDNUriIfUriIsV "https://anystorage.blob.core.windows.net/")] // [TestCase( - "https://anystorage.blob.core.windows.net?sv=2022-11-02&ss=b&srt=co&sp=rtf&se=2024-07-02T05:15:29Z&st=2024-07-01T21:15:29Z&spr=https", + "https://anystorage.blob.core.windows.net?sv=2022-11-02&ss=b&srt=co&sp=rtf&se=2024-07-02T05:15:29Z&st=2024-07-01T21:15:29Z&spr=https", "https://anystorage.blob.core.windows.net/?sv=2022-11-02&ss=b&srt=co&sp=rtf&se=2024-07-02T05:15:29Z&st=2024-07-01T21:15:29Z&spr=https")] // [TestCase( @@ -230,7 +230,7 @@ public void EndpointUtilityCreatesTheExpectedBlobStoreReferenceForConnectionStri [Test] [TestCase("https://any.service.azure.com?miid=307591a4-abb2-4559-af59-b47177d140cf", "https://any.service.azure.com")] - [TestCase("https://any.service.azure.com/?miid=307591a4-abb2-4559-af59-b47177d140cf","https://any.service.azure.com/")] + [TestCase("https://any.service.azure.com/?miid=307591a4-abb2-4559-af59-b47177d140cf", "https://any.service.azure.com/")] public void EndpointUtilityCreatesTheExpectedBlobStoreReferenceForUrisReferencingManagedIdentities(string uri, string expectedUri) { DependencyBlobStore store = EndpointUtility.CreateBlobStoreReference( @@ -338,7 +338,7 @@ public void EndpointUtilityCreatesTheExpectedBlobStoreReferenceForConnectionStri Assert.IsNotNull(store.Credentials); Assert.IsInstanceOf(store.Credentials); } - + [Test] [TestCase("https://any.service.azure.com/?cid=307591a4-abb2-4559-af59-b47177d140cf&tid=985bbc17-e3a5-4fec-b0cb-40dbb8bc5959&crti=ABC&crts=any.domain.com", "https://any.service.azure.com/")] [TestCase("https://any.service.azure.com/?cid=307591a4-abb2-4559-af59-b47177d140cf&tid=985bbc17-e3a5-4fec-b0cb-40dbb8bc5959&crti=ABC CA 01&crts=any.domain.com", "https://any.service.azure.com/")] @@ -854,5 +854,37 @@ public void CreateKeyVaultStoreReference_ConnectionString_ThrowsOnInvalid() "InvalidConnectionString", this.mockFixture.CertificateManager.Object)); } + + [Test] + [TestCase("https://anyvault.vault.azure.net/?cid=123456&tid=654321")] + [TestCase("https://anycontentstorage.blob.core.windows.net?cid=123456&tid=654321")] + [TestCase("https://anypackagestorage.blob.core.windows.net?tid=654321")] + [TestCase("https://anynamespace.servicebus.windows.net?cid=123456&tid=654321")] + [TestCase("https://my-keyvault.vault.azure.net/?;tid=654321")] + public void TryParseMicrosoftEntraTenantIdReference_Uri_WorksAsExpected(string input) + { + // Arrange + Uri uri = new Uri(input); + bool result = EndpointUtility.TryParseMicrosoftEntraTenantIdReference(uri, out string actualTenantId); + + // Assert + Assert.True(result); + Assert.AreEqual("654321", actualTenantId); + } + + [Test] + [TestCase("https://anycontentstorage.blob.core.windows.net?cid=123456&tenantId=654321")] + [TestCase("https://anypackagestorage.blob.core.windows.net?miid=654321")] + [TestCase("https://my-keyvault.vault.azure.net/;cid=654321")] + public void TryParseMicrosoftEntraTenantIdReference_Uri_ReturnFalseWhenInvalid(string input) + { + // Arrange + Uri uri = new Uri(input); + bool result = EndpointUtility.TryParseMicrosoftEntraTenantIdReference(uri, out string actualTenantId); + + // Assert + Assert.IsFalse(result); + Assert.IsNull(actualTenantId); + } } } diff --git a/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs b/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs index 9294acffa1..dff6394fb1 100644 --- a/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs +++ b/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs @@ -398,6 +398,26 @@ public static bool TryParseCertificateReference(Uri uri, out string issuer, out return TryGetCertificateReferenceForUri(queryParameters, out issuer, out subject); } + /// + /// Tries to parse the Microsoft Entra reference information from the provided uri. If the uri does not contain the correctly formatted client ID + /// and tenant ID information the method will return false, and keep the two out parameters as null. + /// Ex. https://anystore.blob.core.windows.net?cid={clientId};tid={tenantId} + /// + /// The uri to attempt to parse the values from. + /// The tenant ID from the Microsoft Entra reference. + /// True/False if the method was able to successfully parse both the client ID and the tenant ID from the Microsoft Entra reference. + public static bool TryParseMicrosoftEntraTenantIdReference(Uri uri, out string tenantId) + { + string queryString = Uri.UnescapeDataString(uri.Query).Trim('?').Replace("&", ",,,"); + + IDictionary queryParameters = TextParsingExtensions.ParseDelimitedValues(queryString)?.ToDictionary( + entry => entry.Key, + entry => entry.Value?.ToString(), + StringComparer.OrdinalIgnoreCase); + + return TryGetMicrosoftEntraTenantId(queryParameters, out tenantId); + } + /// /// Returns the endpoint by verifying package uri checks. /// if the endpoint is a package uri without http or https protocols then append the protocol else return the endpoint value. @@ -1292,5 +1312,23 @@ private static bool TryGetMicrosoftEntraReferenceForUri(IDictionary uriParameters, out string tenantId) + { + bool parametersDefined = false; + tenantId = null; + + if (uriParameters?.Any() == true) + { + if (uriParameters.TryGetValue(UriParameter.TenantId, out string microsoftEntraTenantId) + && !string.IsNullOrWhiteSpace(microsoftEntraTenantId)) + { + tenantId = microsoftEntraTenantId; + parametersDefined = true; + } + } + + return parametersDefined; + } } } diff --git a/src/VirtualClient/VirtualClient.Core/IKeyVaultManager.cs b/src/VirtualClient/VirtualClient.Core/IKeyVaultManager.cs index 4a4d19329a..2c256b1450 100644 --- a/src/VirtualClient/VirtualClient.Core/IKeyVaultManager.cs +++ b/src/VirtualClient/VirtualClient.Core/IKeyVaultManager.cs @@ -3,6 +3,7 @@ namespace VirtualClient { + using System; using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; diff --git a/src/VirtualClient/VirtualClient.Core/Identity/AccessTokenCredential.cs b/src/VirtualClient/VirtualClient.Core/Identity/AccessTokenCredential.cs new file mode 100644 index 0000000000..2ae1eada56 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Core/Identity/AccessTokenCredential.cs @@ -0,0 +1,58 @@ +namespace VirtualClient.Identity +{ + using System; + using System.Threading; + using System.Threading.Tasks; + using Azure.Core; + using VirtualClient.Common.Extensions; + + /// + /// A implementation that uses a pre-acquired + /// access token. + /// + public class AccessTokenCredential : TokenCredential + { + /// + /// Creates a new instance of the class. + /// + /// + /// The access token string to use for authentication. + /// + public AccessTokenCredential(string token) + { + token.ThrowIfNull(nameof(token)); + this.AccessToken = new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1)); + } + + /// + /// The access token to use for authentication. + /// + public AccessToken AccessToken { get; } + + /// + /// Gets an access token using the underlying credentials. + /// + /// Context information used when getting the access token. + /// A token that can be used to cancel the operation. + /// + /// An access token that can be used to authenticate with Azure resources. + /// + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return this.AccessToken; + } + + /// + /// Gets an access token using the underlying credentials. + /// + /// Context information used when getting the access token. + /// A token that can be used to cancel the operation. + /// + /// An access token that can be used to authenticate with Azure resources. + /// + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(this.AccessToken); + } + } +} diff --git a/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs b/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs index de1b82a6a6..03b811a12f 100644 --- a/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs +++ b/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs @@ -15,7 +15,6 @@ namespace VirtualClient using Azure.Security.KeyVault.Secrets; using Polly; using VirtualClient.Common.Extensions; - using VirtualClient.Contracts; /// /// Provides methods for retrieving secrets, keys, and certificates from an Azure Key Vault. diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs new file mode 100644 index 0000000000..606bb112ec --- /dev/null +++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs @@ -0,0 +1,572 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Dependencies +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Net.Http; + using System.Security.Cryptography; + using System.Security.Cryptography.X509Certificates; + using System.Threading; + using System.Threading.Tasks; + using Azure.Core; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.DependencyInjection.Extensions; + using Moq; + using NUnit.Framework; + using Polly; + using VirtualClient.Common.Telemetry; + using VirtualClient.Contracts; + using VirtualClient.Identity; + + [TestFixture] + [Category("Unit")] + public class CertificateInstallationTests + { + private MockFixture mockFixture; + private X509Certificate2 testCertificate; + + [SetUp] + public void SetupDefaultBehaviors() + { + this.mockFixture = new MockFixture(); + + using (RSA rsa = RSA.Create(2048)) + { + var request = new CertificateRequest( + "CN=TestCertificate", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + this.testCertificate = request.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1)); + } + } + + [TearDown] + public void Cleanup() + { + this.testCertificate?.Dispose(); + } + + [Test] + public async Task InitializeAsync_LoadsAccessTokenFromParameter() + { + this.mockFixture.Setup(PlatformID.Win32NT); + + string expectedToken = "test-access-token-12345"; + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.AccessToken), expectedToken }, + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } + }; + + using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + await component.InitializeAsync(EventContext.None, CancellationToken.None); + Assert.AreEqual(expectedToken, component.AccessToken); + } + } + + [Test] + public async Task InitializeAsync_LoadsAccessTokenFromFile() + { + this.mockFixture.Setup(PlatformID.Win32NT); + + string expectedToken = "file-access-token-67890"; + string tokenFilePath = "/tmp/token.txt"; + + this.mockFixture.File.Setup(f => f.ReadAllTextAsync(tokenFilePath, It.IsAny())) + .ReturnsAsync(expectedToken); + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.AccessTokenPath), tokenFilePath }, + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } + }; + + using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + await component.InitializeAsync(EventContext.None, CancellationToken.None); + Assert.AreEqual(expectedToken, component.AccessToken); + } + } + + [Test] + public async Task InitializeAsync_PrefersAccessTokenParameterOverFile() + { + this.mockFixture.Setup(PlatformID.Win32NT); + + string parameterToken = "parameter-token-123"; + string fileToken = "file-token-321"; + string tokenFilePath = "/tmp/token.txt"; + + this.mockFixture.File.Setup(f => f.ReadAllTextAsync(tokenFilePath, It.IsAny())) + .ReturnsAsync(fileToken); + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.AccessToken), parameterToken }, + { nameof(CertificateInstallation.AccessTokenPath), tokenFilePath }, + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } + }; + + using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + await component.InitializeAsync(EventContext.None, CancellationToken.None); + Assert.AreEqual(parameterToken, component.AccessToken); + } + } + + [Test] + public void ExecuteAsync_ThrowsWhenCertificateNameIsNull() + { + this.mockFixture.Setup(PlatformID.Win32NT); + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } + }; + + using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + Exception exception = Assert.ThrowsAsync( + () => component.ExecuteAsync(EventContext.None, CancellationToken.None)); + + Assert.IsNotNull(exception); + Assert.IsTrue(exception.Message.Contains("An entry with key 'CertificateName' does not exist in the dictionary.")); + } + } + + [Test] + public async Task ExecuteAsyncInstallsCertificateOnWindows() + { + this.mockFixture.Setup(PlatformID.Win32NT); + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } + }; + + bool windowsInstallCalled = false; + + using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + this.mockFixture.KeyVaultManager + .Setup(m => m.GetCertificateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(this.testCertificate); + + component.OnInstallCertificateOnWindows = (cert, token) => + { + windowsInstallCalled = true; + Assert.AreEqual(this.testCertificate, cert); + return Task.CompletedTask; + }; + + await component.ExecuteAsync(EventContext.None, CancellationToken.None); + } + + Assert.IsTrue(windowsInstallCalled); + this.mockFixture.KeyVaultManager.Verify(m => m.GetCertificateAsync( + "testCert", + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Once); + } + + [Test] + public async Task ExecuteAsyncInstallsCertificateOnUnix() + { + this.mockFixture.Setup(PlatformID.Unix); + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } + }; + + bool unixInstallCalled = false; + + using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + this.mockFixture.KeyVaultManager + .Setup(m => m.GetCertificateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(this.testCertificate); + + component.OnInstallCertificateOnUnix = (cert, token) => + { + unixInstallCalled = true; + Assert.AreEqual(this.testCertificate, cert); + return Task.CompletedTask; + }; + + await component.ExecuteAsync(EventContext.None, CancellationToken.None); + } + + Assert.IsTrue(unixInstallCalled); + this.mockFixture.KeyVaultManager.Verify(m => m.GetCertificateAsync( + "testCert", + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Once); + } + + [Test] + public void ExecuteAsync_WrapsExceptionsInDependencyException() + { + this.mockFixture.Setup(PlatformID.Win32NT); + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } + }; + + this.mockFixture.KeyVaultManager + .Setup(m => m.GetCertificateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("KeyVault error")); + + using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + DependencyException exception = Assert.ThrowsAsync( + () => component.ExecuteAsync(EventContext.None, CancellationToken.None)); + + StringAssert.Contains("error occurred installing the certificate", exception.Message); + Assert.IsInstanceOf(exception.InnerException); + } + } + + [Test] + public async Task InstallCertificateOnWindowsAsync_InstallsCertificateToCurrentUserStore() + { + this.mockFixture.Setup(PlatformID.Win32NT); + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } + }; + + using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + // This test verifies the method completes without throwing errors. + // Better approach is to create an abstraction. + await component.InstallCertificateRespectively(PlatformID.Win32NT, this.testCertificate, CancellationToken.None); + } + } + + [Test] + public async Task InstallCertificateOnUnixAsync_InstallsCertificateForRegularUser() + { + this.mockFixture.Setup(PlatformID.Unix); + string certificateDirectory = "/home/testuser/.dotnet/corefx/cryptography/x509stores/my"; + string certificatePath = this.mockFixture.Combine(certificateDirectory, $"{this.testCertificate.Thumbprint}.pfx"); + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } + }; + + this.mockFixture.Directory.Setup(d => d.Exists(certificateDirectory)).Returns(false); + this.mockFixture.Directory.Setup(d => d.CreateDirectory(certificateDirectory)); + this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(certificatePath, It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + + this.mockFixture.ProcessManager.OnCreateProcess = (exe, arguments, workingDir) => + { + if (exe == "chmod") + { + Assert.AreEqual($"-R 777 {certificateDirectory}", arguments); + } + + return new InMemoryProcess() + { + ExitCode = 0, + OnStart = () => true, + OnHasExited = () => true + }; + }; + + using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + component.SetEnvironmentVariable(EnvironmentVariable.USER, "testuser"); + await component.InstallCertificateRespectively(PlatformID.Unix, this.testCertificate, CancellationToken.None); + } + + this.mockFixture.Directory.Verify(d => d.CreateDirectory(certificateDirectory), Times.Once); + this.mockFixture.File.Verify(f => f.WriteAllBytesAsync(certificatePath, It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task InstallCertificateOnUnixAsync_InstallsCertificateForSudoUser() + { + this.mockFixture.Setup(PlatformID.Unix); + + string certificateDirectory = "/home/sudouser/.dotnet/corefx/cryptography/x509stores/my"; + string certificatePath = this.mockFixture.Combine(certificateDirectory, $"{this.testCertificate.Thumbprint}.pfx"); + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } + }; + + this.mockFixture.Directory.Setup(d => d.Exists(certificateDirectory)).Returns(false); + this.mockFixture.Directory.Setup(d => d.CreateDirectory(certificateDirectory)); + this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(certificatePath, It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + + this.mockFixture.ProcessManager.OnCreateProcess = (exe, arguments, workingDir) => + { + return new InMemoryProcess() + { + ExitCode = 0, + OnStart = () => true, + OnHasExited = () => true + }; + }; + + using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + component.SetEnvironmentVariable(EnvironmentVariable.USER, "root"); + component.SetEnvironmentVariable(EnvironmentVariable.SUDO_USER, "sudouser"); + await component.InstallCertificateRespectively(PlatformID.Unix, this.testCertificate, CancellationToken.None); + } + + this.mockFixture.Directory.Verify(d => d.CreateDirectory(certificateDirectory), Times.Once); + this.mockFixture.File.Verify(f => f.WriteAllBytesAsync(certificatePath, It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task InstallCertificateOnUnixAsync_InstallsCertificateForRootUser() + { + this.mockFixture.Setup(PlatformID.Unix); + + string certificateDirectory = "/root/.dotnet/corefx/cryptography/x509stores/my"; + string certificatePath = this.mockFixture.Combine(certificateDirectory, $"{this.testCertificate.Thumbprint}.pfx"); + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } + }; + + this.mockFixture.Directory.Setup(d => d.Exists(certificateDirectory)).Returns(true); + this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(certificatePath, It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + + this.mockFixture.ProcessManager.OnCreateProcess = (exe, arguments, workingDir) => + { + return new InMemoryProcess() + { + ExitCode = 0, + OnStart = () => true, + OnHasExited = () => true + }; + }; + + using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + component.SetEnvironmentVariable(EnvironmentVariable.USER, "root"); + await component.InstallCertificateRespectively(PlatformID.Unix, this.testCertificate, CancellationToken.None); + } + + this.mockFixture.Directory.Verify(d => d.CreateDirectory(certificateDirectory), Times.Never); + this.mockFixture.File.Verify(f => f.WriteAllBytesAsync(certificatePath, It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public void InstallCertificateOnUnixAsync_ThrowsUnauthorizedAccessExceptionWithAppropriateMessage() + { + this.mockFixture.Setup(PlatformID.Unix); + + string certificateDirectory = "/home/testuser/.dotnet/corefx/cryptography/x509stores/my"; + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } + }; + + this.mockFixture.Directory.Setup(d => d.Exists(certificateDirectory)).Returns(false); + this.mockFixture.Directory.Setup(d => d.CreateDirectory(certificateDirectory)).Throws(new UnauthorizedAccessException()); + + using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + component.SetEnvironmentVariable(EnvironmentVariable.USER, "testuser"); + + UnauthorizedAccessException exception = Assert.ThrowsAsync( + () => component.InstallCertificateRespectively(PlatformID.Unix, this.testCertificate, CancellationToken.None)); + + StringAssert.Contains("Access permissions denied", exception.Message); + StringAssert.Contains("sudo/root privileges", exception.Message); + } + } + + [Test] + public void GetKeyVaultManager_ReturnsInjectedKeyVaultManager() + { + this.mockFixture.Setup(PlatformID.Win32NT); + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" } + }; + + using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + IKeyVaultManager manager = component.GetKeyVaultManager(); + + Assert.IsNotNull(manager); + Assert.AreSame(this.mockFixture.KeyVaultManager.Object, manager); + } + } + + [Test] + public async Task GetKeyVaultManager_CreatesKeyVaultManagerWithAccessToken() + { + this.mockFixture.Setup(PlatformID.Win32NT); + + // Remove the injected KeyVaultManager + this.mockFixture.Dependencies.RemoveAll(); + + this.mockFixture.KeyVaultManager = new Mock(MockBehavior.Loose); + this.mockFixture.Dependencies.AddSingleton((p) => this.mockFixture.KeyVaultManager.Object); + + string accessToken = "test-token-abc123"; + string keyVaultUri = "https://testvault.vault.azure.net/"; + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.AccessToken), accessToken }, + { nameof(CertificateInstallation.KeyVaultUri), keyVaultUri } + }; + + using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + await component.InitializeAsync(EventContext.None, CancellationToken.None); + IKeyVaultManager manager = component.GetKeyVaultManager(); + + Assert.IsNotNull(manager); + Assert.IsNotNull(manager.StoreDescription); + Assert.AreEqual(keyVaultUri, ((DependencyKeyVaultStore)manager.StoreDescription).EndpointUri.ToString()); + } + } + + [Test] + public async Task GetKeyVaultManagerWithTokenThrowsWhenKeyVaultUriNotProvided() + { + this.mockFixture.Setup(PlatformID.Win32NT); + + // Remove the injected KeyVaultManager + this.mockFixture.Dependencies.RemoveAll(); + + this.mockFixture.KeyVaultManager = new Mock(MockBehavior.Loose); + this.mockFixture.Dependencies.AddSingleton((p) => this.mockFixture.KeyVaultManager.Object); + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.AccessToken), "test-token" } + }; + + using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + await component.InitializeAsync(EventContext.None, CancellationToken.None); + Assert.Throws(() => component.GetKeyVaultManager()); + } + } + + [Test] + public void GetKeyVaultManager_ThrowsWhenNoKeyVaultManagerOrAccessTokenProvided() + { + this.mockFixture.Setup(PlatformID.Win32NT); + + // Remove the injected KeyVaultManager and setup one without StoreDescription + this.mockFixture.Dependencies.RemoveAll(); + var emptyMock = new Mock(); + emptyMock.Setup(m => m.StoreDescription).Returns((DependencyKeyVaultStore)null); + this.mockFixture.Dependencies.AddSingleton(emptyMock.Object); + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" } + }; + + using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + InvalidOperationException exception = Assert.Throws( + () => component.GetKeyVaultManager()); + + StringAssert.Contains("Key Vault manager has not been properly initialized", exception.Message); + } + } + + private class TestCertificateInstallation : CertificateInstallation + { + public TestCertificateInstallation(IServiceCollection dependencies, IDictionary parameters) + : base(dependencies, parameters) + { + } + + public Func OnInstallCertificateOnWindows { get; set; } + + public Func OnInstallCertificateOnUnix { get; set; } + + public new Task InitializeAsync(EventContext context, CancellationToken cancellationToken) + { + return base.InitializeAsync(context, cancellationToken); + } + + public new Task ExecuteAsync(EventContext context, CancellationToken cancellationToken) + { + return base.ExecuteAsync(context, cancellationToken); + } + + public new IKeyVaultManager GetKeyVaultManager() + { + return base.GetKeyVaultManager(); + } + + public Task InstallCertificateRespectively(PlatformID platformID, X509Certificate2 certificate, CancellationToken cancellationToken) + { + if (platformID == PlatformID.Unix) + { + return this.InstallCertificateOnUnixAsync(certificate, cancellationToken); + } + + return this.InstallCertificateOnWindowsAsync(certificate, cancellationToken); + } + + protected override Task InstallCertificateOnUnixAsync(X509Certificate2 certificate, CancellationToken cancellationToken) + { + return this.OnInstallCertificateOnUnix != null + ? this.OnInstallCertificateOnUnix(certificate, cancellationToken) + : base.InstallCertificateOnUnixAsync(certificate, cancellationToken); + } + + protected override Task InstallCertificateOnWindowsAsync(X509Certificate2 certificate, CancellationToken cancellationToken) + { + return this.OnInstallCertificateOnWindows != null + ? this.OnInstallCertificateOnWindows(certificate, cancellationToken) + : base.InstallCertificateOnWindowsAsync(certificate, cancellationToken); + } + } + } +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/KeyVaultAccessTokenTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/KeyVaultAccessTokenTests.cs new file mode 100644 index 0000000000..551b341873 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/KeyVaultAccessTokenTests.cs @@ -0,0 +1,326 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Dependencies +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using Azure.Core; + using Azure.Identity; + using Moq; + using NUnit.Framework; + using VirtualClient.Common.Telemetry; + + [TestFixture] + [Category("Unit")] + public class KeyVaultAccessTokenTests + { + private MockFixture mockFixture; + + [SetUp] + public void Setup() + { + this.mockFixture = new MockFixture(); + } + + [Test] + [TestCase(PlatformID.Unix)] + [TestCase(PlatformID.Win32NT)] + public async Task InitializeWillNotDoAnythingIfLogFileNameIsNotProvided(PlatformID platform) + { + this.mockFixture.Setup(platform); + + this.SetupWorkingDirectory(platform, out _); + + using (TestKeyVaultAccessToken component = new TestKeyVaultAccessToken(this.mockFixture.Dependencies, this.CreateDefaultParameters())) + { + await component.InitializeAsyncInternal(EventContext.None, CancellationToken.None).ConfigureAwait(false); + + Assert.IsNull(component.AccessTokenPathInternal); + } + } + + [Test] + [TestCase(PlatformID.Unix)] + [TestCase(PlatformID.Win32NT)] + public async Task InitializeWillEnsureAccessTokenPathIsReadyIfLogFileNameIsProvided(PlatformID platform) + { + this.mockFixture.Setup(platform); + + this.SetupWorkingDirectory(platform, out string workingDir); + + string expectedPath = this.Combine(workingDir, "AccessToken.txt"); + + // Setup: file does not exist initially + this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(false); + + using (TestKeyVaultAccessToken component = new TestKeyVaultAccessToken(this.mockFixture.Dependencies, this.CreateDefaultParameters())) + { + component.Parameters["LogFileName"] = "AccessToken.txt"; + + await component.InitializeAsyncInternal(EventContext.None, CancellationToken.None).ConfigureAwait(false); + + Assert.AreEqual(expectedPath, component.AccessTokenPathInternal); + Assert.IsFalse(this.mockFixture.File.Object.Exists(component.AccessTokenPathInternal), "File should not be created during Initialize."); + } + } + + [Test] + [TestCase(PlatformID.Unix)] + [TestCase(PlatformID.Win32NT)] + public async Task InitializeWillEnsureOldFileIsDeletedIfPresent(PlatformID platform) + { + this.mockFixture.Setup(platform); + + this.SetupWorkingDirectory(platform, out string workingDir); + + string tokenPath = this.Combine(workingDir, "AccessToken.txt"); + + // Setup: existing token file is present and should be deleted during Initialize. + this.mockFixture.File.Setup(f => f.Exists(tokenPath)).Returns(true); + + bool deleteCalled = false; + this.mockFixture.FileSystem + .Setup(f => f.File.Delete(It.IsAny())) + .Callback(() => deleteCalled = true); + + using (TestKeyVaultAccessToken component = new TestKeyVaultAccessToken(this.mockFixture.Dependencies, this.CreateDefaultParameters())) + { + component.Parameters["LogFileName"] = "AccessToken.txt"; + + await component.InitializeAsyncInternal(EventContext.None, CancellationToken.None).ConfigureAwait(false); + + Assert.IsTrue(deleteCalled, "Existing token file should be deleted."); + this.mockFixture.File.Verify(f => f.Delete(It.IsAny()), Times.Once); + } + } + + [Test] + [TestCase(PlatformID.Unix)] + [TestCase(PlatformID.Win32NT)] + public void ExecuteAsyncValidatesRequiredParameters(PlatformID platform) + { + this.mockFixture.Setup(platform); + + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + + using (TestKeyVaultAccessToken component = new TestKeyVaultAccessToken(this.mockFixture.Dependencies, parameters)) + { + Assert.ThrowsAsync(() => component.ExecuteAsync(CancellationToken.None)); + } + } + + [Test] + [TestCase(PlatformID.Unix)] + [TestCase(PlatformID.Win32NT)] + public async Task ExecuteAsyncWillWriteTokenToFileWhenLogFileNameIsProvided(PlatformID platform) + { + this.mockFixture.Setup(platform); + this.SetupWorkingDirectory(platform, out string workingDir); + + string tokenContent = Guid.NewGuid().ToString(); + string expectedPath = this.Combine(workingDir, "AccessToken.txt"); + + 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(expectedPath, path); + Assert.AreEqual(FileMode.Create, mode); + Assert.AreEqual(FileAccess.ReadWrite, access); + Assert.AreEqual(FileShare.ReadWrite, share); + }); + + mockFileStream + .Setup(x => x.Write(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((byte[] data, int offset, int count) => + { + byte[] byteData = Encoding.Default.GetBytes(tokenContent); + Assert.AreEqual(0, offset); + Assert.AreEqual(byteData.Length, count); + CollectionAssert.AreEqual(byteData, data); + }); + + using (TestKeyVaultAccessToken component = new TestKeyVaultAccessToken(this.mockFixture.Dependencies, this.CreateDefaultParameters())) + { + component.Parameters["LogFileName"] = "AccessToken.txt"; + component.InteractiveTokenToReturn = tokenContent; + + await component.InitializeAsyncInternal(EventContext.None, CancellationToken.None).ConfigureAwait(false); + await component.ExecuteAsync(CancellationToken.None).ConfigureAwait(false); + } + } + + [Test] + [TestCase(PlatformID.Unix)] + [TestCase(PlatformID.Win32NT)] + public void GetTokenRequestContextWillReturnCorrectValue(PlatformID platform) + { + this.mockFixture.Setup(platform); + + using (TestKeyVaultAccessToken component = new TestKeyVaultAccessToken(this.mockFixture.Dependencies, this.CreateDefaultParameters())) + { + TokenRequestContext ctx = component.GetTokenRequestContextInternal(); + + Assert.IsNotNull(ctx); + Assert.AreEqual(1, ctx.Scopes.Length); + Assert.AreEqual("https://myvault.vault.azure.net/.default", ctx.Scopes[0]); + } + } + + [Test] + [TestCase(PlatformID.Unix)] + [TestCase(PlatformID.Win32NT)] + public async Task ExecuteAsyncWillUseInteractiveTokenFirst(PlatformID platform) + { + this.mockFixture.Setup(platform); + + using (TestKeyVaultAccessToken component = new TestKeyVaultAccessToken(this.mockFixture.Dependencies, this.CreateDefaultParameters())) + { + component.InteractiveTokenToReturn = "interactive-ok"; + + await component.ExecuteAsync(CancellationToken.None).ConfigureAwait(false); + + Assert.AreEqual(1, component.InteractiveCalls); + Assert.AreEqual(0, component.DeviceCodeCalls); + } + } + + [Test] + [TestCase(PlatformID.Unix)] + [TestCase(PlatformID.Win32NT)] + public async Task ExecuteAsyncWillUseDeviceLoginIfInteractiveFailsWithExactError(PlatformID platform) + { + this.mockFixture.Setup(platform); + + using (TestKeyVaultAccessToken component = new TestKeyVaultAccessToken(this.mockFixture.Dependencies, this.CreateDefaultParameters())) + { + component.ThrowBrowserUnavailableAuthenticationFailedException = true; + component.DeviceCodeTokenToReturn = "device-code-ok"; + + await component.ExecuteAsync(CancellationToken.None).ConfigureAwait(false); + + Assert.AreEqual(1, component.InteractiveCalls); + Assert.AreEqual(1, component.DeviceCodeCalls); + } + } + + [Test] + [TestCase(PlatformID.Unix, null)] + [TestCase(PlatformID.Win32NT, null)] + [TestCase(PlatformID.Unix, "")] + [TestCase(PlatformID.Win32NT, "")] + [TestCase(PlatformID.Unix, " ")] + [TestCase(PlatformID.Win32NT, " ")] + [TestCase(PlatformID.Unix, "validToken")] + [TestCase(PlatformID.Win32NT, "validToken")] + public void ExecuteAsyncThrowsErrorIfTokenIsNullOrWhitespace(PlatformID platform, string token) + { + this.mockFixture.Setup(platform); + + using (TestKeyVaultAccessToken component = new TestKeyVaultAccessToken(this.mockFixture.Dependencies, this.CreateDefaultParameters())) + { + component.InteractiveTokenToReturn = token; + + if (string.IsNullOrWhiteSpace(token)) + { + Assert.ThrowsAsync(() => component.ExecuteAsync(CancellationToken.None)); + } + else + { + Assert.DoesNotThrowAsync(() => component.ExecuteAsync(CancellationToken.None)); + } + } + } + + private void SetupWorkingDirectory(PlatformID platform, out string workingDir) + { + workingDir = platform == PlatformID.Win32NT ? @"C:\home\user" : "/home/user"; + + // KeyVaultAccessToken uses ISystemManagement.FileSystem internally, which in unit tests is MockFixture.FileSystem + this.mockFixture.Directory.Setup(d => d.GetCurrentDirectory()).Returns(workingDir); + } + + private string Combine(string left, string right) + { + // Avoid relying on host OS behavior; use the path separator expected by the test platform. + char sep = this.mockFixture.Platform == PlatformID.Win32NT ? '\\' : '/'; + return $"{left.TrimEnd(sep)}{sep}{right.TrimStart(sep)}"; + } + + private IDictionary CreateDefaultParameters() + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "TenantId", "00000000-0000-0000-0000-000000000000" }, + { "KeyVaultUri", "https://myvault.vault.azure.net/" } + }; + } + + private sealed class TestKeyVaultAccessToken : KeyVaultAccessToken + { + public TestKeyVaultAccessToken(Microsoft.Extensions.DependencyInjection.IServiceCollection dependencies, IDictionary parameters) + : base(dependencies, parameters) + { + } + + public string InteractiveTokenToReturn { get; set; } = "interactive-token"; + + public string DeviceCodeTokenToReturn { get; set; } = "device-token"; + + public bool ThrowBrowserUnavailableAuthenticationFailedException { get; set; } + + public int InteractiveCalls { get; private set; } + + public int DeviceCodeCalls { get; private set; } + + public string AccessTokenPathInternal => this.AccessTokenPath; + + public Task InitializeAsyncInternal(EventContext context, CancellationToken token) + { + return this.InitializeAsync(context, token); + } + + public TokenRequestContext GetTokenRequestContextInternal() + { + if (this.Parameters.ContainsKey(nameof(this.KeyVaultUri))) + { + this.KeyVaultUri = this.Parameters[nameof(this.KeyVaultUri)].ToString(); + } + + return this.GetTokenRequestContext(); + } + + protected override async Task AcquireInteractiveTokenAsync( + TokenCredential credential, + TokenRequestContext requestContext, + CancellationToken cancellationToken) + { + this.InteractiveCalls++; + + if (this.ThrowBrowserUnavailableAuthenticationFailedException) + { + throw new AuthenticationFailedException("Unable to open a web page"); + } + + await Task.Yield(); + return this.InteractiveTokenToReturn; + } + + protected override async Task AcquireDeviceCodeTokenAsync( + TokenCredential credential, + TokenRequestContext requestContext, + CancellationToken cancellationToken) + { + this.DeviceCodeCalls++; + await Task.Yield(); + return this.DeviceCodeTokenToReturn; + } + } + } +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs new file mode 100644 index 0000000000..8af44c84ee --- /dev/null +++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs @@ -0,0 +1,249 @@ +namespace VirtualClient.Dependencies +{ + using System; + using System.Collections.Generic; + using System.IO.Abstractions; + using System.Security.Cryptography.X509Certificates; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using VirtualClient.Common; + using VirtualClient.Common.Extensions; + using VirtualClient.Common.Telemetry; + using VirtualClient.Contracts; + using VirtualClient.Identity; + + /// + /// Virtual Client component that installs certificates from Azure Key Vault + /// into the appropriate certificate store for the operating system. + /// + public class CertificateInstallation : VirtualClientComponent + { + private ISystemManagement systemManagement; + private IFileSystem fileSystem; + private ProcessManager processManager; + + /// + /// Initializes a new instance of the class. + /// + /// Provides all of the required dependencies to the Virtual Client component. + /// Parameters to the Virtual Client component. + public CertificateInstallation(IServiceCollection dependencies, IDictionary parameters = null) + : base(dependencies, parameters) + { + this.systemManagement = dependencies.GetService(); + this.fileSystem = this.systemManagement.FileSystem; + this.processManager = this.systemManagement.ProcessManager; + } + + /// + /// Gets the Azure Key Vault URI for which the access token will be requested. + /// Example: https://anyvault.vault.azure.net/ + /// + public string KeyVaultUri + { + get + { + return this.Parameters.GetValue(nameof(this.KeyVaultUri)); + } + } + + /// + /// The name of the certificate to be retrieved + /// + public string CertificateName + { + get + { + return this.Parameters.GetValue(nameof(this.CertificateName)); + } + } + + /// + /// Gets the path to the file where the access token is saved. + /// + public string AccessTokenPath + { + get + { + return this.Parameters.GetValue(nameof(this.AccessTokenPath)); + } + } + + /// + /// Flag to decode whether to retrieve certificate with private key + /// + public bool WithPrivateKey + { + get + { + return this.Parameters.GetValue(nameof(this.WithPrivateKey), true); + } + } + + /// + /// Gets the access token used to authenticate with Azure services. + /// + public string AccessToken { get; set; } + + /// + /// Initializes the component by resolving the access token from parameters or, if necessary, from a file. + /// + protected override async Task InitializeAsync(EventContext telemetryContext, CancellationToken cancellationToken) + { + this.AccessToken = this.Parameters.GetValue(nameof(this.AccessToken), string.Empty); + + if (string.IsNullOrWhiteSpace(this.AccessToken) && !string.IsNullOrWhiteSpace(this.AccessTokenPath)) + { + this.AccessToken = await this.fileSystem.File.ReadAllTextAsync(this.AccessTokenPath); + } + } + + /// + /// Acquires an access token for the configured Key Vault URI using Azure Identity. + /// The component attempts interactive browser authentication first and falls back to + /// device-code authentication when a browser is not available (e.g. headless Linux). + /// The token is always written to standard output. Token is also written to a file if AccessTokenPath is resolved. + /// + protected override async Task ExecuteAsync(EventContext telemetryContext, CancellationToken cancellationToken) + { + this.CertificateName.ThrowIfNullOrWhiteSpace(nameof(this.CertificateName)); + + try + { + IKeyVaultManager keyVault = this.GetKeyVaultManager(); + X509Certificate2 certificate = await keyVault.GetCertificateAsync(this.CertificateName, cancellationToken, null, this.WithPrivateKey); + + if (this.Platform == PlatformID.Win32NT) + { + await this.InstallCertificateOnWindowsAsync(certificate, cancellationToken); + } + else if (this.Platform == PlatformID.Unix) + { + await this.InstallCertificateOnUnixAsync(certificate, cancellationToken); + } + else + { + throw new PlatformNotSupportedException($"The '{nameof(CertificateInstallation)}' component is not supported on platform '{this.Platform}'."); + } + } + catch (Exception exc) + { + throw new DependencyException( + $"An error occurred installing the certificate '{this.CertificateName}' from Key Vault. See inner exception for details.", + exc); + } + } + + /// + /// Installs the certificate in the appropriate certificate store on a Windows system. + /// + protected virtual Task InstallCertificateOnWindowsAsync(X509Certificate2 certificate, CancellationToken cancellationToken) + { + return Task.Run(() => + { + Console.WriteLine($"Certificate Store = CurrentUser/Personal"); + using (X509Store store = new X509Store(StoreName.My, StoreLocation.CurrentUser, OpenFlags.ReadWrite)) + { + store.Open(OpenFlags.ReadWrite); + store.Add(certificate); + store.Close(); + } + }); + } + + /// + /// Installs the certificate in the appropriate certificate store on a Unix/Linux system. + /// + protected virtual async Task InstallCertificateOnUnixAsync(X509Certificate2 certificate, CancellationToken cancellationToken) + { + // On Unix/Linux systems, we install the certificate in the default location for the + // user as well as in a static location. In the future we will likely use the static location + // only. + string certificateDirectory = null; + + try + { + // When "sudo" is used to run the installer, we need to know the logged + // in user account. On Linux systems, there is an environment variable 'SUDO_USER' + // that defines the logged in user. + + string user = this.GetEnvironmentVariable(EnvironmentVariable.USER); + string sudoUser = this.GetEnvironmentVariable(EnvironmentVariable.SUDO_USER); + certificateDirectory = $"/home/{user}/.dotnet/corefx/cryptography/x509stores/my"; + + if (!string.IsNullOrWhiteSpace(sudoUser)) + { + // The installer is being executed with "sudo" privileges. We want to use the + // logged in user profile vs. "root". + certificateDirectory = $"/home/{sudoUser}/.dotnet/corefx/cryptography/x509stores/my"; + } + else if (user == "root") + { + // The installer is being executed from the "root" account on Linux. + certificateDirectory = $"/root/.dotnet/corefx/cryptography/x509stores/my"; + } + + Console.WriteLine($"Certificate Store = {certificateDirectory}"); + + if (!this.fileSystem.Directory.Exists(certificateDirectory)) + { + this.fileSystem.Directory.CreateDirectory(certificateDirectory); + } + + using (X509Store store = new X509Store(StoreName.My, StoreLocation.CurrentUser, OpenFlags.ReadWrite)) + { + store.Open(OpenFlags.ReadWrite); + store.Add(certificate); + store.Close(); + } + + await this.fileSystem.File.WriteAllBytesAsync( + this.Combine(certificateDirectory, $"{certificate.Thumbprint}.pfx"), + certificate.Export(X509ContentType.Pfx)); + + // Permissions 777 (-rwxrwxrwx) + // https://linuxhandbook.com/linux-file-permissions/ + using (IProcessProxy process = this.processManager.CreateProcess("chmod", $"-R 777 {certificateDirectory}")) + { + await process.StartAndWaitAsync(cancellationToken); + process.ThrowIfErrored(); + } + } + catch (UnauthorizedAccessException) + { + throw new UnauthorizedAccessException( + $"Access permissions denied for certificate directory '{certificateDirectory}'. Execute the installer with " + + $"sudo/root privileges to install SDK certificates in privileged locations."); + } + } + + /// + /// Gets the Key Vault manager to use to retrieve certificates from Key Vault. + /// + protected IKeyVaultManager GetKeyVaultManager() + { + IKeyVaultManager keyVaultManager = this.Dependencies.GetService(); + keyVaultManager.ThrowIfNull(nameof(keyVaultManager)); + + if (keyVaultManager.StoreDescription != null) + { + return keyVaultManager; + } + else if (!string.IsNullOrWhiteSpace(this.AccessToken)) + { + this.KeyVaultUri.ThrowIfNullOrWhiteSpace(nameof(this.KeyVaultUri), "The KeyVaultUri parameter is required when authenticating with Key Vault using an access token."); + + AccessTokenCredential tokenCredential = new AccessTokenCredential(this.AccessToken); + + DependencyKeyVaultStore dependencyKeyVault = new DependencyKeyVaultStore(DependencyStore.KeyVault, new Uri(this.KeyVaultUri), tokenCredential); + return new KeyVaultManager(dependencyKeyVault); + } + else + { + throw new InvalidOperationException($"The Key Vault manager has not been properly initialized. " + + $"Either valid --KeyVault or --Token or --TokenPath must be passed in order to set up authentication with Key Vault."); + } + } + } +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs new file mode 100644 index 0000000000..a89449208e --- /dev/null +++ b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs @@ -0,0 +1,218 @@ +// 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.Text; + using System.Threading; + using System.Threading.Tasks; + using Azure.Core; + using Azure.Identity; + using Microsoft.Extensions.DependencyInjection; + using VirtualClient.Common.Extensions; + using VirtualClient.Common.Telemetry; + using VirtualClient.Contracts; + + /// + /// Virtual Client component that acquires an Azure access token for the specified Key Vault + /// using interactive browser authentication with a device-code fallback. + /// + public class KeyVaultAccessToken : VirtualClientComponent + { + private IFileSystem fileSystem; + + /// + /// Initializes a new instance of the class. + /// + /// Provides all of the required dependencies to the Virtual Client component. + /// Parameters to the Virtual Client component. + public KeyVaultAccessToken(IServiceCollection dependencies, IDictionary parameters = null) + : base(dependencies, parameters) + { + this.fileSystem = dependencies.GetService(); + this.fileSystem.ThrowIfNull(nameof(this.fileSystem)); + } + + /// + /// Gets the Azure Key Vault URI for which the access token will be requested. + /// Example: https://anyvault.vault.azure.net/ + /// + protected string KeyVaultUri { get; set; } + + /// + /// Gets the Azure tenant ID used to acquire an access token. + /// + protected string TenantId { get; set; } + + /// + /// Gets or sets the full file path where the acquired access token will be written when file logging is enabled. + /// This is resolved during when + /// is provided. + /// + protected string AccessTokenPath { get; set; } + + /// + /// Resolves the access token output file path + /// and removes any existing token file so the current run produces a fresh token output. + /// + protected override async Task InitializeAsync(EventContext telemetryContext, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(this.LogFileName)) + { + string directory = !string.IsNullOrWhiteSpace(this.LogFolderName) + ? this.LogFolderName + : this.fileSystem.Directory.GetCurrentDirectory(); + + this.AccessTokenPath = this.Combine(directory, this.LogFileName); + + if (this.fileSystem.File.Exists(this.AccessTokenPath)) + { + await this.fileSystem.File.DeleteAsync(this.AccessTokenPath); + } + } + } + + /// + /// Acquires an access token for the configured Key Vault URI using Azure Identity. + /// The component attempts interactive browser authentication first and falls back to + /// device-code authentication when a browser is not available (e.g. headless Linux). + /// The token is always written to standard output. Token is also written to a file if AccessTokenPath is resolved. + /// + protected override async Task ExecuteAsync(EventContext telemetryContext, CancellationToken cancellationToken) + { + this.KeyVaultUri = this.Parameters.GetValue(nameof(this.KeyVaultUri)); + this.KeyVaultUri.ThrowIfNullOrWhiteSpace(nameof(this.KeyVaultUri)); + + this.TenantId = this.Parameters.GetValue(nameof(this.TenantId)); + if (string.IsNullOrWhiteSpace(this.TenantId)) + { + EndpointUtility.TryParseMicrosoftEntraTenantIdReference(new Uri(this.KeyVaultUri), out string tenant); + this.TenantId = tenant; + } + + this.TenantId.ThrowIfNullOrWhiteSpace(nameof(this.TenantId)); + + string accessToken = null; + if (!cancellationToken.IsCancellationRequested) + { + TokenRequestContext requestContext = this.GetTokenRequestContext(); + try + { + // Attempt an interactive (browser-based) authentication first. On most Windows environments + // this will work and is the most convenient for the user. On many Linux systems, there may + // not be a GUI and thus no browser. In that case, we fall back to the device code credential + // option in the catch block below. + InteractiveBrowserCredential credential = new InteractiveBrowserCredential( + new InteractiveBrowserCredentialOptions + { + TenantId = this.TenantId + }); + + accessToken = await this.AcquireInteractiveTokenAsync(credential, requestContext, cancellationToken); + } + catch (AuthenticationFailedException exc) when (exc.Message.Contains("Unable to open a web page")) + { + // Browser-based authentication is unavailable; switch to device code flow and present + // the user with a code and URL to complete authentication from another device. + DeviceCodeCredential credential = new DeviceCodeCredential(new DeviceCodeCredentialOptions + { + TenantId = this.TenantId, + DeviceCodeCallback = (codeInfo, token) => + { + Console.WriteLine(string.Empty); + Console.WriteLine("Browser-based authentication unavailable (e.g. no GUI). Using device/code option."); + Console.WriteLine(string.Empty); + Console.WriteLine("********************** Azure Key Vault Authorization **********************"); + Console.WriteLine(string.Empty); + Console.WriteLine(codeInfo.Message); + Console.WriteLine(string.Empty); + Console.WriteLine("***************************************************************************"); + Console.WriteLine(string.Empty); + + return Task.CompletedTask; + } + }); + + accessToken = await this.AcquireDeviceCodeTokenAsync(credential, requestContext, cancellationToken); + } + + if (string.IsNullOrWhiteSpace(accessToken)) + { + throw new AuthenticationFailedException("Authentication failed. No access token could be obtained."); + } + + if (!string.IsNullOrEmpty(this.AccessTokenPath)) + { + using (FileSystemStream fileStream = this.fileSystem.FileStream.New( + this.AccessTokenPath, + FileMode.Create, + FileAccess.ReadWrite, + FileShare.ReadWrite)) + { + byte[] bytedata = Encoding.Default.GetBytes(accessToken); + fileStream.Write(bytedata, 0, bytedata.Length); + await fileStream.FlushAsync().ConfigureAwait(false); + this.Logger.LogTraceMessage($"Access token saved to file: {this.AccessTokenPath}"); + } + } + + Console.WriteLine("[Access Token]:"); + Console.WriteLine(accessToken); + } + } + + /// + /// Acquires an access token using interactive browser authentication. + /// + /// The interactive browser credential to use. + /// The request context containing the required scopes. + /// A token that can be used to cancel the operation. + /// The access token string. + protected virtual async Task AcquireInteractiveTokenAsync( + TokenCredential credential, + TokenRequestContext requestContext, + CancellationToken cancellationToken) + { + AccessToken response = await credential.GetTokenAsync(requestContext, cancellationToken); + return response.Token; + } + + /// + /// Acquires an access token using device-code authentication. + /// This is used as a fallback when interactive browser authentication is unavailable. + /// + /// The device code credential to use. + /// The request context containing the required scopes. + /// A token that can be used to cancel the operation. + /// The access token string. + protected virtual async Task AcquireDeviceCodeTokenAsync( + TokenCredential credential, + TokenRequestContext requestContext, + CancellationToken cancellationToken) + { + AccessToken response = await credential.GetTokenAsync(requestContext, cancellationToken); + return response.Token; + } + + /// + /// Creates the used to request an access token for the target Key Vault resource. + /// Uses the Key Vault resource scope: "{KeyVaultUri}/.default". + /// + /// The token request context containing the required scopes. + protected virtual TokenRequestContext GetTokenRequestContext() + { + string[] installerTenantResourceScopes = new string[] + { + new Uri(new Uri(this.KeyVaultUri), ".default").ToString(), + // Example of a specific scope: + // "api://56e7ee83-1cf6-4048-a664-c2a08955f825/user_impersonation" + }; + + return new TokenRequestContext(scopes: installerTenantResourceScopes); + } + } +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.IntegrationTests/KeyVaultManagerTests.cs b/src/VirtualClient/VirtualClient.IntegrationTests/KeyVaultManagerTests.cs deleted file mode 100644 index a9c1666faa..0000000000 --- a/src/VirtualClient/VirtualClient.IntegrationTests/KeyVaultManagerTests.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace VirtualClient -{ - internal class KeyVaultManagerTests - { - } -} diff --git a/src/VirtualClient/VirtualClient.Main/BootstrapPackageCommand.cs b/src/VirtualClient/VirtualClient.Main/BootstrapPackageCommand.cs index 4f9212d03f..10c31a1aed 100644 --- a/src/VirtualClient/VirtualClient.Main/BootstrapPackageCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/BootstrapPackageCommand.cs @@ -3,19 +3,39 @@ namespace VirtualClient { + using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; + using VirtualClient.Common.Extensions; + using VirtualClient.Common.Telemetry; using VirtualClient.Contracts; /// - /// Command executes the operations to bootstrap/install dependencies on the system - /// prior to running a Virtual Client profile. + /// Command executes bootstrap operations including package installation and certificate installation. /// internal class BootstrapPackageCommand : ExecuteProfileCommand { + /// + /// When true, Key Vault will be initialized. This is only needed when using default Azure authentication + /// (no access token provided). + /// + protected override bool ShouldInitializeKeyVault => string.IsNullOrWhiteSpace(this.AccessToken); + + /// + /// The name of the certificate to install from Key Vault. + /// Optional - if not provided, only package installation will occur. + /// + public string CertificateName { get; set; } + + /// + /// Optional access token for Key Vault authentication when installing certificates. + /// When not provided, uses default Azure credential authentication (Azure CLI, Managed Identity, etc.). + /// + public string AccessToken { get; set; } + /// /// The name (logical name) to use when registering the package. /// @@ -24,21 +44,19 @@ internal class BootstrapPackageCommand : ExecuteProfileCommand /// /// The name of the package (in storage) to bootstrap/install. /// - public string Package { get; set; } + public string PackageName { get; set; } /// - /// Executes the dependency bootstrap/installation operations. + /// Executes the bootstrap command. + /// Supports: + /// - Package installation from remote store (requires --packages, optionally --package for specific package name) + /// - Certificate installation (requires --cert-name and --key-vault) + /// - Both certificate and package installation (--cert-name --key-vault --packages) /// - /// 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) { - string registerAsName = this.Name; - if (String.IsNullOrWhiteSpace(registerAsName)) - { - registerAsName = Path.GetFileNameWithoutExtension(this.Package); - } + // Validate that at least one operation is requested + this.ValidateParameters(); this.Timeout = ProfileTiming.OneIteration(); this.Profiles = new List @@ -46,15 +64,72 @@ public override Task ExecuteAsync(string[] args, CancellationTokenSource ca new DependencyProfileReference("BOOTSTRAP-DEPENDENCIES.json") }; + var scenariosToExecute = new List(); + + // Scenario 1: Certificate installation only OR Certificate + Package installation + if (!string.IsNullOrWhiteSpace(this.CertificateName)) + { + scenariosToExecute.Add("InstallCertificate"); + this.SetupCertificateInstallation(); + } + + // Scenario 2: Package installation (can be standalone or after certificate) + if (!string.IsNullOrWhiteSpace(this.PackageName)) + { + scenariosToExecute.Add("InstallDependencies"); + this.SetupPackageInstallation(); + } + + this.Scenarios = scenariosToExecute; + return base.ExecuteAsync(args, cancellationTokenSource); + } + + protected void ValidateParameters() + { if (this.Parameters == null) { this.Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); } - this.Parameters["Package"] = this.Package; - this.Parameters["RegisterAsName"] = registerAsName; + // At least one operation must be specified + if (string.IsNullOrWhiteSpace(this.PackageName) && string.IsNullOrWhiteSpace(this.CertificateName)) + { + throw new ArgumentException( + "At least one operation must be specified. Use --package for package installation from remote store " + + "or --cert-name for certificate installation."); + } - return base.ExecuteAsync(args, cancellationTokenSource); + // If certificate installation is requested, KeyVault URI is required + if (!string.IsNullOrWhiteSpace(this.CertificateName) && string.IsNullOrWhiteSpace(this.KeyVault)) + { + throw new ArgumentException( + "The Key Vault URI must be provided (--key-vault) when installing certificates (--cert-name)."); + } + } + + protected void SetupCertificateInstallation() + { + // Set certificate-related parameters + this.Parameters["KeyVaultUri"] = this.KeyVault; + this.Parameters["CertificateName"] = this.CertificateName; + + if (!string.IsNullOrWhiteSpace(this.AccessToken)) + { + // Token-based authentication - no Key Vault initialization needed + this.Parameters["AccessToken"] = this.AccessToken; + } + } + + protected void SetupPackageInstallation() + { + string registerAsName = this.Name; + if (String.IsNullOrWhiteSpace(registerAsName)) + { + registerAsName = Path.GetFileNameWithoutExtension(this.PackageName); + } + + this.Parameters["Package"] = this.PackageName; + this.Parameters["RegisterAsName"] = registerAsName; } } } diff --git a/src/VirtualClient/VirtualClient.Main/CommandBase.cs b/src/VirtualClient/VirtualClient.Main/CommandBase.cs index a846f42f01..e494a1cce0 100644 --- a/src/VirtualClient/VirtualClient.Main/CommandBase.cs +++ b/src/VirtualClient/VirtualClient.Main/CommandBase.cs @@ -586,7 +586,7 @@ protected virtual IServiceCollection InitializeDependencies(string[] args) blobStores.Add(DependencyFactory.CreateBlobManager(this.ContentStore)); } - if (this.KeyVault != null) + if (this.KeyVault != null && this.ShouldInitializeKeyVault) { DependencyKeyVaultStore keyVaultStore = EndpointUtility.CreateKeyVaultStoreReference(DependencyStore.KeyVault, endpoint: this.KeyVault, this.CertificateManager ?? new CertificateManager()); keyVaultManager = DependencyFactory.CreateKeyVaultManager(keyVaultStore); @@ -953,5 +953,11 @@ private string EvaluatePathReplacements(string path) return FileContext.ResolvePathTemplate(path, this.pathReplacements); } + + /// + /// Determines whether the Key Vault manager should be initialized during dependency setup. + /// Can be overridden by derived commands that need to skip Key Vault initialization. + /// + protected virtual bool ShouldInitializeKeyVault => true; } } diff --git a/src/VirtualClient/VirtualClient.Main/GetAccessTokenCommand.cs b/src/VirtualClient/VirtualClient.Main/GetAccessTokenCommand.cs new file mode 100644 index 0000000000..778a40f7aa --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/GetAccessTokenCommand.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Threading; + using System.Threading.Tasks; + using VirtualClient.Contracts; + + /// + /// Command that executes a profile to acquire an access token for an Azure Key Vault. + /// + internal class GetAccessTokenCommand : ExecuteProfileCommand + { + /// + /// Key vault initialization is not required for getting an access token. + /// + protected override bool ShouldInitializeKeyVault => false; + + /// + /// Executes the access token acquisition operations using the configured profile. + /// + /// 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) + { + this.Timeout = ProfileTiming.OneIteration(); + this.Profiles = new List + { + new DependencyProfileReference("GET-ACCESS-TOKEN.json") + }; + + if (this.Parameters == null) + { + this.Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + this.Parameters["KeyVaultUri"] = this.KeyVault; + + return base.ExecuteAsync(args, cancellationTokenSource); + } + } +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs index 19d9a5b32a..b801f55d7c 100644 --- a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs +++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs @@ -595,6 +595,44 @@ public static Option CreateKeyVaultOption(bool required = false, object defaultV OptionFactory.SetOptionRequirements(option, required, defaultValue); + return option; + } + + /// + /// Command line option defines the authentication token for Key Vault to authenticate requests. + /// + /// Sets this option as required. + /// Sets the default value when none is provided. + public static Option CreateTokenOption(bool required = false, object defaultValue = null) + { + Option option = new Option(new string[] { "--token", "--access-token" }) + { + Name = "AccessToken", + Description = "Authentication token for Azure Key Vault access. When not provided, uses default Azure credential authentication (Azure CLI, Managed Identity, etc.).", + ArgumentHelpName = "token", + AllowMultipleArgumentsPerToken = false + }; + + OptionFactory.SetOptionRequirements(option, required, defaultValue); + return option; + } + + /// + /// Command line option defines the certificate name to retrieve from Key Vault. + /// + /// Sets this option as required. + /// Sets the default value when none is provided. + public static Option CreateCertificateNameOption(bool required = false, object defaultValue = null) + { + Option option = new Option(new string[] { "--certname", "--certificate-name", "--cert-name" }) + { + Name = "CertificateName", + Description = "The name of the certificate in Azure Key Vault to install to the local certificate store.", + ArgumentHelpName = "name", + AllowMultipleArgumentsPerToken = false + }; + + OptionFactory.SetOptionRequirements(option, required, defaultValue); return option; } @@ -898,7 +936,7 @@ public static Option CreatePackageOption(bool required = false, object defaultVa { Option option = new Option(new string[] { "--pkg", "--package" }) { - Name = "Package", + Name = "PackageName", Description = "The physical name of a package to bootstrap/install as it is defined in a package store (e.g. anypackage.1.0.0.zip).", ArgumentHelpName = "name", AllowMultipleArgumentsPerToken = false @@ -1604,7 +1642,6 @@ private static ProfileTiming ParseProfileTimeout(ArgumentResult parsedResult) // --timeout=1440 // --timeout=1440,deterministic // --timeout=1440,deterministic* - // // --timeout=01:00:00 // --timeout=01:00:00,deterministic // --timeout=01:00:00,deterministic* diff --git a/src/VirtualClient/VirtualClient.Main/Program.cs b/src/VirtualClient/VirtualClient.Main/Program.cs index db50e303ed..6e364131a9 100644 --- a/src/VirtualClient/VirtualClient.Main/Program.cs +++ b/src/VirtualClient/VirtualClient.Main/Program.cs @@ -310,7 +310,10 @@ internal static CommandLineBuilder SetupCommandLine(string[] args, CancellationT OptionFactory.CreateTimeoutOption(required: false), // --verbose - OptionFactory.CreateVerboseFlag(required: false, false) + OptionFactory.CreateVerboseFlag(required: false, false), + + // --token + OptionFactory.CreateTokenOption(required: false) }; // Single command execution is also supported. Behind the scenes this uses a @@ -325,6 +328,11 @@ internal static CommandLineBuilder SetupCommandLine(string[] args, CancellationT apiSubcommand.Handler = CommandHandler.Create(cmd => cmd.ExecuteAsync(args, cancellationTokenSource)); rootCommand.Add(apiSubcommand); + Command getAccessTokenSubcommand = Program.CreateGetTokenSubcommand(settings); + getAccessTokenSubcommand.TreatUnmatchedTokensAsErrors = true; + getAccessTokenSubcommand.Handler = CommandHandler.Create(cmd => cmd.ExecuteAsync(args, cancellationTokenSource)); + rootCommand.Add(getAccessTokenSubcommand); + Command bootstrapSubcommand = Program.CreateBootstrapSubcommand(settings); bootstrapSubcommand.TreatUnmatchedTokensAsErrors = true; bootstrapSubcommand.Handler = CommandHandler.Create(cmd => cmd.ExecuteAsync(args, cancellationTokenSource)); @@ -406,19 +414,64 @@ private static Command CreateApiSubcommand(DefaultSettings settings) return apiCommand; } + private static Command CreateGetTokenSubcommand(DefaultSettings settings) + { + Command getAccessTokenCommand = new Command( + "get-token", + "Get access token for current user to authenticate with Azure Key Vault.") + { + // REQUIRED + // ------------------------------------------------------------------- + // --key-vault + OptionFactory.CreateKeyVaultOption(required: true), + + // OPTIONAL + // ------------------------------------------------------------------- + // --clean + OptionFactory.CreateCleanOption(required: false), + + // --client-id + OptionFactory.CreateClientIdOption(required: false, Guid.NewGuid().ToString()), + + // --experiment-id + OptionFactory.CreateExperimentIdOption(required: false, Guid.NewGuid().ToString()), + + // --parameters + OptionFactory.CreateParametersOption(required: false), + + // --verbose + OptionFactory.CreateVerboseFlag(required: false, false) + }; + + return getAccessTokenCommand; + } + private static Command CreateBootstrapSubcommand(DefaultSettings settings) { + // --package + Option pkgOption = OptionFactory.CreatePackageOption(required: false); + + // --cert-name + Option certNameOption = OptionFactory.CreateCertificateNameOption(required: false); + + // --key-vault + Option kvOption = OptionFactory.CreateKeyVaultOption(required: false); + Command bootstrapCommand = new Command( "bootstrap", "Bootstraps/installs a dependency package on the system.") { // REQUIRED // ------------------------------------------------------------------- - // --package - OptionFactory.CreatePackageOption(required: true), // OPTIONAL // ------------------------------------------------------------------- + + pkgOption, certNameOption, kvOption, + + // --token + OptionFactory.CreateTokenOption(required: false), + // --clean OptionFactory.CreateCleanOption(required: false), @@ -443,9 +496,6 @@ private static Command CreateBootstrapSubcommand(DefaultSettings settings) // --iterations (for integration only. not used/always = 1) OptionFactory.CreateIterationsOption(required: false), - // --key-vault - OptionFactory.CreateKeyVaultOption(required: false), - // --layout-path (for integration only. not used.) OptionFactory.CreateLayoutPathOption(required: false), @@ -495,6 +545,34 @@ private static Command CreateBootstrapSubcommand(DefaultSettings settings) OptionFactory.CreateVerboseFlag(required: false, false) }; + bootstrapCommand.AddValidator(result => + { + string packageName = result.FindResultFor(pkgOption)?.GetValueOrDefault(); + string certNameValue = result.FindResultFor(certNameOption)?.GetValueOrDefault(); + string keyVaultValue = result.FindResultFor(kvOption)?.GetValueOrDefault(); + + bool packageProvided = !string.IsNullOrWhiteSpace(packageName); + bool certificateNameProvided = !string.IsNullOrWhiteSpace(certNameValue); + bool keyVaultProvided = !string.IsNullOrWhiteSpace(keyVaultValue); + + // Must choose at least one operation. + if (!packageProvided && !certificateNameProvided) + { + result.ErrorMessage = "At least one operation must be specified for the bootstrap command." + + "Use --package to install a package or --cert-name to install a certificate."; + return result.ErrorMessage; + } + + // Certificate installation requires both --cert-name and --key-vault. + if (certificateNameProvided && !keyVaultProvided) + { + result.ErrorMessage = "The Key Vault URI must be provided (--key-vault) when installing certificates (--cert-name)."; + return result.ErrorMessage; + } + + return null; + }); + return bootstrapCommand; } diff --git a/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-DEPENDENCIES.json b/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-DEPENDENCIES.json index 88f94a6d63..7090548328 100644 --- a/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-DEPENDENCIES.json +++ b/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-DEPENDENCIES.json @@ -1,19 +1,31 @@ { - "Description": "Installs dependencies from a package store.", - "Parameters": { - "Package": "Undefined", - "RegisterAsName": "Undefined" + "Description": "Installs dependencies from a package store.", + "Parameters": { + "KeyVaultUri": null, + "CertificateName": null, + "AccessToken": null, + "Package": "Undefined", + "RegisterAsName": "Undefined" + }, + "Actions": [ + { + "Type": "CertificateInstallation", + "Parameters": { + "Scenario": "InstallCertificate", + "KeyVaultUri": "$.Parameters.KeyVaultUri", + "CertificateName": "$.Parameters.CertificateName", + "AccessToken": "$.Parameters.AccessToken" + } }, - "Dependencies": [ - { - "Type": "DependencyPackageInstallation", - "Parameters": { - "Scenario": "InstallDependencies", - "BlobContainer": "packages", - "BlobName": "$.Parameters.Package", - "PackageName": "$.Parameters.RegisterAsName", - "Extract": true - } - } - ] + { + "Type": "DependencyPackageInstallation", + "Parameters": { + "Scenario": "InstallDependencies", + "BlobContainer": "packages", + "BlobName": "$.Parameters.Package", + "PackageName": "$.Parameters.RegisterAsName", + "Extract": true + } + } + ] } \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Main/profiles/GET-ACCESS-TOKEN.json b/src/VirtualClient/VirtualClient.Main/profiles/GET-ACCESS-TOKEN.json new file mode 100644 index 0000000000..3e6d6f2c4e --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/profiles/GET-ACCESS-TOKEN.json @@ -0,0 +1,19 @@ +{ + "Description": "Get access token for the user that can be used to authenticate.", + "Parameters": { + "KeyVaultUri": null, + "TenantId": null, + "LogFileName": "AccessToken.txt" + }, + "Dependencies": [ + { + "Type": "KeyVaultAccessToken", + "Parameters": { + "Scenario": "GetKVAccessToken", + "TenantId": "$.Parameters.TenantId", + "KeyVaultUri": "$.Parameters.KeyVaultUri", + "LogFileName": "$.Parameters.LogFileName" + } + } + ] +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs b/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs index 0aadadf5ab..63adc3139a 100644 --- a/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs +++ b/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs @@ -247,6 +247,11 @@ public string PlatformArchitectureName /// public Mock SystemManagement { get; set; } + /// + /// A mock Key Vault Manager + /// + public Mock KeyVaultManager { get; set; } + /// /// A mock profile timing/timeout definition. /// @@ -471,6 +476,7 @@ public virtual MockFixture Setup(PlatformID platform, Architecture architecture return mockFile.Object; }); + this.KeyVaultManager = new Mock(mockBehavior); this.FileSystem.Setup(fs => fs.Path.GetDirectoryName(It.IsAny())) .Returns(path => MockFixture.GetDirectoryName(path)); @@ -601,6 +607,9 @@ public virtual MockFixture Setup(PlatformID platform, Architecture architecture new NetworkInterfaceInfo("Mellanox Technologies MT27800 Family [ConnectX-5 Virtual Function] (rev 80)", "Mellanox Technologies MT27800 Family [ConnectX-5 Virtual Function] (rev 80)"), })); + this.KeyVaultManager.Setup(kv => kv.StoreDescription) + .Returns(new DependencyKeyVaultStore(DependencyStore.KeyVault, new Uri("https://testvault.vault.azure.net/"))); + this.Dependencies = new ServiceCollection(); this.Dependencies.AddSingleton((p) => this.Logger); this.Dependencies.AddSingleton(new ConfigurationBuilder().Build()); @@ -619,6 +628,7 @@ public virtual MockFixture Setup(PlatformID platform, Architecture architecture this.Dependencies.AddSingleton((p) => this.Layout); this.Dependencies.AddSingleton((p) => this.ApiClientManager.Object); this.Dependencies.AddSingleton((p) => this.Timing); + this.Dependencies.AddSingleton((p) => this.KeyVaultManager.Object); this.Dependencies.AddSingleton>(new List { this.ContentBlobManager.Object, diff --git a/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandValidatorTests.cs b/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandValidatorTests.cs new file mode 100644 index 0000000000..f83b8ba8b3 --- /dev/null +++ b/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandValidatorTests.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient +{ + using System; + using System.CommandLine.Builder; + using System.CommandLine.Parsing; + using System.Threading; + using NUnit.Framework; + + [TestFixture] + [Category("Unit")] + public class BootstrapCommandValidatorTests + { + [Test] + public void BootstrapCommand_RequiresAtLeastOneOperation() + { + using CancellationTokenSource tokenSource = new CancellationTokenSource(); + CommandLineBuilder commandBuilder = Program.SetupCommandLine(Array.Empty(), tokenSource); + + ParseResult parseResult = commandBuilder.Build().Parse(new[] { "bootstrap" }); + + ArgumentException exe = Assert.Throws(() => parseResult.ThrowOnUsageError()); + StringAssert.Contains("At least one operation must be specified for the bootstrap command.", exe!.Message); + } + + [Test] + [TestCase("--cert-name")] + [TestCase("--certname")] + [TestCase("--certificate-name")] + public void BootstrapCommand_CertificateInstall_RequiresKeyVault(string certAlias) + { + using CancellationTokenSource tokenSource = new CancellationTokenSource(); + CommandLineBuilder commandBuilder = Program.SetupCommandLine(Array.Empty(), tokenSource); + + ParseResult parseResult = commandBuilder.Build().Parse(new[] { "bootstrap", certAlias, "mycertName" }); + + ArgumentException exe = Assert.Throws(() => parseResult.ThrowOnUsageError()); + StringAssert.Contains("The Key Vault URI must be provided (--key-vault)", exe!.Message); + } + + [Test] + [TestCase("--package")] + [TestCase("--pkg")] + public void BootstrapCommand_PackageInstall_DoesNotRequireKeyVault(string packageAlias) + { + using CancellationTokenSource tokenSource = new CancellationTokenSource(); + CommandLineBuilder commandBuilder = Program.SetupCommandLine(Array.Empty(), tokenSource); + + ParseResult parseResult = commandBuilder.Build().Parse(new[] { "bootstrap", packageAlias, "mypackage" }); + + Assert.DoesNotThrow(() => parseResult.ThrowOnUsageError()); + } + + [Test] + [TestCase("--kv")] + [TestCase("--key-vault")] + public void BootstrapCommand_CertificateInstall_WithKeyVault_IsValid(string kvAlias) + { + using CancellationTokenSource tokenSource = new CancellationTokenSource(); + CommandLineBuilder commandBuilder = Program.SetupCommandLine(Array.Empty(), tokenSource); + + ParseResult parseResult = commandBuilder.Build().Parse(new[] + { + "bootstrap", + "--cert-name", "mycert", + kvAlias, "https://myvault.vault.azure.net/" + }); + + Assert.DoesNotThrow(() => parseResult.ThrowOnUsageError()); + } + } +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.UnitTests/BootstrapPackageCommandTests.cs b/src/VirtualClient/VirtualClient.UnitTests/BootstrapPackageCommandTests.cs new file mode 100644 index 0000000000..1dffce3c68 --- /dev/null +++ b/src/VirtualClient/VirtualClient.UnitTests/BootstrapPackageCommandTests.cs @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient +{ + using System; + using System.Collections.Generic; + using System.IO; + using NUnit.Framework; + + [TestFixture] + [Category("Unit")] + public class BootstrapPackageCommandTests + { + [Test] + public void ValidateParameters_InitializesParametersDictionary_WhenNull() + { + var command = new TestBootstrapPackageCommand + { + Parameters = null, + PackageName = "anypackage.zip" + }; + + command.ValidateParametersPublic(); + + Assert.That(command.Parameters, Is.Not.Null); + Assert.That(command.Parameters, Is.InstanceOf>()); + } + + [Test] + public void ValidateParameters_Throws_WhenNoOperationsSpecified() + { + var command = new TestBootstrapPackageCommand + { + PackageName = null, + CertificateName = null + }; + + var ex = Assert.Throws(() => command.ValidateParametersPublic()); + StringAssert.Contains("At least one operation must be specified", ex!.Message); + } + + [Test] + public void ValidateParameters_Throws_WhenCertNameProvidedWithoutKeyVault() + { + var command = new TestBootstrapPackageCommand + { + CertificateName = "mycert", + KeyVault = null + }; + + var ex = Assert.Throws(() => command.ValidateParametersPublic()); + StringAssert.Contains("The Key Vault URI must be provided (--key-vault)", ex!.Message); + } + + [Test] + public void ValidateParameters_DoesNotThrow_WhenPackageProvided() + { + var command = new TestBootstrapPackageCommand + { + PackageName = "anypackage.zip", + CertificateName = null + }; + + Assert.DoesNotThrow(() => command.ValidateParametersPublic()); + } + + [Test] + public void ValidateParameters_DoesNotThrow_WhenCertNameAndKeyVaultProvided() + { + var command = new TestBootstrapPackageCommand + { + CertificateName = "mycert", + KeyVault = "https://myvault.vault.azure.net/" + }; + + Assert.DoesNotThrow(() => command.ValidateParametersPublic()); + } + + [Test] + public void SetupCertificateInstallation_SetsExpectedParameters_WhenNoAccessToken() + { + var command = new TestBootstrapPackageCommand + { + Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase), + CertificateName = "mycert", + KeyVault = "https://myvault.vault.azure.net/", + AccessToken = null + }; + + command.SetupCertificateInstallationPublic(); + + Assert.That(command.Parameters["KeyVaultUri"], Is.EqualTo(command.KeyVault)); + Assert.That(command.Parameters["CertificateName"], Is.EqualTo(command.CertificateName)); + Assert.That(command.Parameters.ContainsKey("AccessToken"), Is.False); + } + + [Test] + public void SetupCertificateInstallation_SetsExpectedParameters_WhenAccessTokenProvided() + { + var command = new TestBootstrapPackageCommand + { + Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase), + CertificateName = "mycert", + KeyVault = "https://myvault.vault.azure.net/", + AccessToken = "token" + }; + + command.SetupCertificateInstallationPublic(); + + Assert.That(command.Parameters["KeyVaultUri"], Is.EqualTo(command.KeyVault)); + Assert.That(command.Parameters["CertificateName"], Is.EqualTo(command.CertificateName)); + Assert.That(command.Parameters["AccessToken"], Is.EqualTo(command.AccessToken)); + } + + [Test] + public void SetupPackageInstallation_SetsRegisterAsNameToName_WhenProvided() + { + var command = new TestBootstrapPackageCommand + { + Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase), + PackageName = "mypackage.zip", + Name = "customname" + }; + + command.SetupPackageInstallationPublic(); + + Assert.That(command.Parameters["Package"], Is.EqualTo(command.PackageName)); + Assert.That(command.Parameters["RegisterAsName"], Is.EqualTo("customname")); + } + + [Test] + public void SetupPackageInstallation_SetsRegisterAsNameToPackageFileName_WhenNameNotProvided() + { + var command = new TestBootstrapPackageCommand + { + Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase), + PackageName = Path.Combine("C:\\packages", "mypackage.zip"), + Name = null + }; + + command.SetupPackageInstallationPublic(); + + Assert.That(command.Parameters["Package"], Is.EqualTo(command.PackageName)); + Assert.That(command.Parameters["RegisterAsName"], Is.EqualTo("mypackage")); + } + + [Test] + public void ScenarioSelection_CertificateOnly_SetsInstallCertificateOnly() + { + var command = new TestBootstrapPackageCommand + { + CertificateName = "mycert", + KeyVault = "https://myvault.vault.azure.net/", + PackageName = null + }; + + var scenarios = command.ComputeScenariosAndSetupParameters(); + + CollectionAssert.AreEqual(new[] { "InstallCertificate" }, scenarios); + Assert.That(command.Parameters["KeyVaultUri"], Is.EqualTo(command.KeyVault)); + Assert.That(command.Parameters["CertificateName"], Is.EqualTo(command.CertificateName)); + } + + [Test] + public void ScenarioSelection_PackageOnly_SetsInstallDependenciesOnly() + { + var command = new TestBootstrapPackageCommand + { + CertificateName = null, + PackageName = "mypackage.zip", + Name = null + }; + + var scenarios = command.ComputeScenariosAndSetupParameters(); + + CollectionAssert.AreEqual(new[] { "InstallDependencies" }, scenarios); + Assert.That(command.Parameters["Package"], Is.EqualTo(command.PackageName)); + Assert.That(command.Parameters["RegisterAsName"], Is.EqualTo("mypackage")); + } + + [Test] + public void ScenarioSelection_CertificateThenPackage_SetsScenariosInOrder() + { + var command = new TestBootstrapPackageCommand + { + CertificateName = "mycert", + KeyVault = "https://myvault.vault.azure.net/", + PackageName = "mypackage.zip", + Name = "regname" + }; + + var scenarios = command.ComputeScenariosAndSetupParameters(); + + CollectionAssert.AreEqual(new[] { "InstallCertificate", "InstallDependencies" }, scenarios); + + // Certificate side effects + Assert.That(command.Parameters["KeyVaultUri"], Is.EqualTo(command.KeyVault)); + Assert.That(command.Parameters["CertificateName"], Is.EqualTo(command.CertificateName)); + + // Package side effects + Assert.That(command.Parameters["Package"], Is.EqualTo(command.PackageName)); + Assert.That(command.Parameters["RegisterAsName"], Is.EqualTo("regname")); + } + + [Test] + public void ShouldInitializeKeyVault_IsTrue_WhenAccessTokenNotProvided() + { + var command = new TestBootstrapPackageCommand + { + AccessToken = null + }; + + Assert.That(command.ShouldInitializeKeyVaultPublic(), Is.True); + } + + [Test] + public void ShouldInitializeKeyVault_IsFalse_WhenAccessTokenIsProvided() + { + var command = new TestBootstrapPackageCommand + { + AccessToken = "helloWorld" + }; + + Assert.That(command.ShouldInitializeKeyVaultPublic(), Is.False); + } + + internal sealed class TestBootstrapPackageCommand : BootstrapPackageCommand + { + public void ValidateParametersPublic() => this.ValidateParameters(); + + public void SetupCertificateInstallationPublic() => this.SetupCertificateInstallation(); + + public void SetupPackageInstallationPublic() => this.SetupPackageInstallation(); + + public bool ShouldInitializeKeyVaultPublic() => this.ShouldInitializeKeyVault; + + public IReadOnlyList ComputeScenariosAndSetupParameters() + { + this.ValidateParameters(); + + var scenariosToExecute = new List(); + + if (!string.IsNullOrWhiteSpace(this.CertificateName)) + { + scenariosToExecute.Add("InstallCertificate"); + this.SetupCertificateInstallation(); + } + + if (!string.IsNullOrWhiteSpace(this.PackageName)) + { + scenariosToExecute.Add("InstallDependencies"); + this.SetupPackageInstallation(); + } + + return scenariosToExecute; + } + } + } +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.UnitTests/CommandLineOptionTests.cs b/src/VirtualClient/VirtualClient.UnitTests/CommandLineOptionTests.cs index 8135448868..edffbafacc 100644 --- a/src/VirtualClient/VirtualClient.UnitTests/CommandLineOptionTests.cs +++ b/src/VirtualClient/VirtualClient.UnitTests/CommandLineOptionTests.cs @@ -608,6 +608,44 @@ public void VirtualClientCommandLineSupportsResponseFiles() } } + [Test] + [TestCase("--agentId", "AgentID")] + [TestCase("--client-id", "AgentID")] + [TestCase("--c", "AgentID")] + [TestCase("--clean", null)] + [TestCase("--clean", "logs")] + [TestCase("--clean", "logs,packages,state,temp")] + [TestCase("--experimentId", "0B692DEB-411E-4AC1-80D5-AF539AE1D6B2")] + [TestCase("--experiment-id", "0B692DEB-411E-4AC1-80D5-AF539AE1D6B2")] + [TestCase("--e", "0B692DEB-411E-4AC1-80D5-AF539AE1D6B2")] + [TestCase("--parameters", "helloWorld=123,,,TenantId=789203498")] + [TestCase("--pm", "testing")] + [TestCase("--verbose", null)] + public void VirtualClientGetTokenCommandSupportsOnlyExpectedOptions(string option, string value) + { + using (CancellationTokenSource cancellationSource = new CancellationTokenSource()) + { + List arguments = new List() + { + "get-token", + "--kv", "https://anyvault.vault.azure.net/?cid=1...&tid=2" + }; + + arguments.Add(option); + if (value != null) + { + arguments.Add(value); + } + + Assert.DoesNotThrow(() => + { + ParseResult result = Program.SetupCommandLine(arguments.ToArray(), cancellationSource).Build().Parse(arguments); + Assert.IsFalse(result.Errors.Any()); + result.ThrowOnUsageError(); + }, $"Option '{option}' is not supported."); + } + } + private class TestExecuteCommand : ExecuteCommand { public Action OnExecuteCommand { get; set; } diff --git a/website/docs/guides/0010-command-line.md b/website/docs/guides/0010-command-line.md index c6b305d419..a734c476c8 100644 --- a/website/docs/guides/0010-command-line.md +++ b/website/docs/guides/0010-command-line.md @@ -136,42 +136,58 @@ VirtualClient.exe ## Subcommands The following tables describe the various subcommands that are supported by the Virtual Client application. + * ### bootstrap - Command is used to bootstrap/install dependency packages on the system. This is used for example to install "extensions" packages to the Virtual Client before they - can be used (see the Developer Guide at the top for information on developing extensions). Note that many of the options below are similar to the default - command documented above. Most are not required but allow the user/automation to use the same correlation identifiers for the bootstrapping operations as will - be used for the profile execution operations that may follow. + Command is used to bootstrap/install dependency packages and/or install a certificate on the system. + + * **Package bootstrapping**: Install an extensions/dependency package to the Virtual Client runtime. + Requires `--package` (and typically `--package-store/--packages` to fetch it from storage). + + * **Certificate bootstrapping**: Install a certificate to the system for use by workloads that require certificate-based authentication. + Requires `--cert-name` **and** `--key-vault`. + Optionally supports `--token` to provide an access token for Key Vault authentication (if not provided, the default Azure credential flow is used). | Option | Required | Data Type | Description | |-----------------------------------------------------------------|----------|------------------------------|-------------| - | --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-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. | - | --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}" | - | --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). See the `Supported Loggers` section at the bottom. | - | --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. | - | --mt, --metadata=\ | No | string/text | Metadata to include with all logs/telemetry output from the Virtual Client. 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. | - | --n, --name=\ | No | string/name | Defines the logical name of a package as it should be registered on the system (e.g. anypackage.1.0.0.zip -> anypackage). | - | --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). | - | --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. | + | --pkg, --package=\ | No* | string/blob name | Name/ID of a package to bootstrap/install (e.g. `anypackage.1.0.0.zip`). Required when doing **package bootstrapping**. | + | --ps, --packages, --package-store=\ | No | string/connection string/SAS | Connection description for an Azure Storage Account/container to download packages from. See [Azure Storage Account Integration](./0600-integration-blob-storage.md). | + | --certificateName, --cert-name=\ | No* | string/certificate name | Name of the certificate in Key Vault to bootstrap/install (e.g. `--cert-name="crc-sdk-cert"`). Required when doing **certificate bootstrapping**. | + | --key-vault, --kv=\ | No* | uri | Azure Key Vault URI to source the certificate from (e.g. `https://myvault.vault.azure.net/`). Required when doing **certificate bootstrapping**. | + | --token, --access-token=\ | No | string | Optional access token used to authenticate to Key Vault when installing certificates. If not provided, Virtual Client uses the default Azure credential flow (e.g. Azure CLI, Managed Identity, etc.). | + | --c, --client-id=\ | No | string/text | Identifier to uniquely identify the instance (telemetry correlation). | + | --clean=\ | No | string | Perform an initial cleanup (logs/packages/state/temp/all). | + | --cs, --content, --content-store=\ | No | string/connection string/SAS | Storage connection for uploading files/content (e.g. logs). | + | --cp, --content-path, --content-path-template=\ | No | string/text | Upload folder structure template. | + | --event-hub=\ | No | string/connection string | Event Hub connection for telemetry upload (deprecated in favor of `--logger=eventhub;...`). | + | --e, --experiment-id=\ | No | guid | Experiment identifier. | + | --isolated | No | | Run with dependency isolation (unique logs/packages/state/temp per experiment). | + | --logger=\ | No | string/path | One or more logger definitions. | + | --ldir, --log-dir=\ | No | string/path | Alternate logs directory. | + | --ll, --log-level=\ | No | integer/string | Trace severity level. | + | --lr, --log-retention=\ | No | timespan or integer | Log retention period. | + | --mt, --metadata=\ | No | string/text | Metadata to include with telemetry output. | + | --n, --name=\ | No | string/name | Logical name to register a package as. | + | --pdir, --package-dir=\ | No | string/path | Alternate packages directory. | + | --sdir, --state-dir=\ | No | string/path | Alternate state directory. | + | --s, --system=\ | No | string/text | Execution system/platform identifier (e.g. Azure). | + | --tdir, --temp-dir=\ | No | string/path | Alternate temp directory. | + | --wait, --exit-wait=\ | No | timespan or integer | Wait for graceful exit/telemetry flush. | + | --verbose | No | | Verbose console logging (equivalent to `--log-level=Trace`). | + | -?, -h, --help | No | | Show help. | + | --version | No | | Show version. + + \*Note: at least one operation must be specified. Use either `--package` (package bootstrapping) or `--cert-name` with `--key-vault` (certificate bootstrapping), or both. ``` bash # Basic command line example + VirtualClient.exe bootstrap --package=diskspd.2.0.21.zip + VirtualClient.exe bootstrap --package=anyworkload.1.0.0.zip --package-store="{BlobStoreConnectionString|SAS URI}" + VirtualClient.exe bootstrap --cert-name="crc-sdk-principal" --key-vault="{KeyVaultConnectionString|SAS URI}" + + VirtualClient.exe bootstrap --cert-name="crc-sdk-principal" --key-vault="{KeyVaultConnectionString|SAS URI}" --token="{AccessToken}" + # Full command line example VirtualClient.exe bootstrap --package=anyworkload.1.0.0.zip @@ -387,6 +403,38 @@ The following tables describe the various subcommands that are supported by the --recursive ``` +* ### get-token + Command is used to retrieve an Azure access token for the **current user**. This token can be supplied to other commands (e.g. `bootstrap`) using the `--token/--access-token` option for explicit authentication against Azure Key Vault. + + This is useful when: + - the default Azure credential flow is not available on the machine, or + - you want to explicitly pass a token to `bootstrap` for Key Vault certificate installation. + + **Authentication experience** + - If browser-based authentication is available, Virtual Client will open/prompt a sign-in in your browser. + - If browser-based authentication is not available, Virtual Client will automatically switch to **device code flow** and display a URL and a short code. Complete sign-in on another authenticated device using the provided URL and code. + + | Option | Required | Data Type | Description | + |-----------------------------------------------|----------|---------------|-------------| + | --kv, --keyvault, --key-vault=\ | Yes | uri | Azure Key Vault URI used as the authentication resource (e.g. `https://myvault.vault.azure.net/`). | + | --clean=\ | No | string | Perform an initial cleanup (logs/packages/state/temp/all). | + | --c, --client-id=\ | No | string/text | Identifier to uniquely identify the instance (telemetry correlation). | + | --e, --experiment-id=\ | No | guid | Experiment identifier. | + | --pm, --parameters=\ | No | string/text | Additional parameters/overrides (optional). | + | --verbose | No | | Verbose console logging (equivalent to `--log-level=Trace`). | + | -?, -h, --help | No | | Show help information. | + | --version | No | | Show application version information. | + +* Note: The tenant ID must be included in the Key Vault URI as a query parameter (e.g., `?tid=`). VC will parse the URI to authenticate and retrieve a token for the current user. + +``` +Examples: + +VirtualClient.exe get-token --key-vault="https://my-keyVault.vault.azure.net/?cid=a5432368-4cf1-4b72-b7f1-443c875f8a01&tid=caa7a00f-0558-4c4a-9613-965683a01f45" + +VirtualClient.exe get-token --key-vault="https://my-keyVault.vault.azure.net/?tid=caa7a00f-0558-4c4a-9613-965683a01f45" +``` + ## Supported Loggers The following section describes the set of loggers supported by the application out-of-box. Note that multiple loggers can be provided on the command line by specifying each using the `--logger` option.