From acfbbbeb609b78797c04228b910e4e5961bc8839 Mon Sep 17 00:00:00 2001
From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com>
Date: Wed, 17 Dec 2025 14:58:59 -0800
Subject: [PATCH 01/28] Adding token
---
.../KeyVaultAccessToken.cs | 182 ++++++++++++++++++
.../GetAccessTokenCommand.cs | 40 ++++
.../VirtualClient.Main/Program.cs | 22 +++
.../profiles/GET-ACCESS-TOKEN.json | 19 ++
4 files changed, 263 insertions(+)
create mode 100644 src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
create mode 100644 src/VirtualClient/VirtualClient.Main/GetAccessTokenCommand.cs
create mode 100644 src/VirtualClient/VirtualClient.Main/profiles/GET-ACCESS-TOKEN.json
diff --git a/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
new file mode 100644
index 0000000000..51cc42cafd
--- /dev/null
+++ b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
@@ -0,0 +1,182 @@
+// 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 access token for an Azure Key Vault
+ /// using interactive browser or device-code authentication.
+ ///
+ 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));
+ }
+
+ ///
+ /// The Azure tenant ID used when requesting an access token for the Key Vault.
+ ///
+ protected string TenantId
+ {
+ get
+ {
+ return this.Parameters.GetValue(nameof(this.TenantId));
+ }
+ }
+
+ ///
+ /// The Azure Key Vault URI for which an access token will be requested.
+ /// Example: https://anyvault.vault.azure.net/
+ ///
+ protected string KeyVaultUri
+ {
+ get
+ {
+ return this.Parameters.GetValue(nameof(this.KeyVaultUri));
+ }
+ }
+
+ ///
+ /// The full file path where the acquired access token will be written,
+ /// when configured via / .
+ ///
+ protected string AccessTokenPath { get; set; }
+
+ ///
+ /// Initializes the component for execution, including resolving the access token
+ /// output path and removing any existing token file if configured.
+ ///
+ 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.fileSystem.Path.GetFullPath(
+ this.fileSystem.Path.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.
+ /// Attempts interactive browser authentication first and falls back to
+ /// device-code authentication when a browser is not available.
+ /// The access token can optionally be written to a file and is always
+ /// written to the console output.
+ ///
+ protected override async Task ExecuteAsync(EventContext telemetryContext, CancellationToken cancellationToken)
+ {
+ this.KeyVaultUri.ThrowIfNullOrWhiteSpace(nameof(this.KeyVaultUri));
+ this.TenantId.ThrowIfNullOrWhiteSpace(nameof(this.TenantId));
+
+ string accessToken = null;
+ if (!cancellationToken.IsCancellationRequested)
+ {
+ string[] installerTenantResourceScopes = new string[]
+ {
+ new Uri(baseUri: new Uri(this.KeyVaultUri), relativeUri: ".default").ToString(),
+ // Example of a specific scope:
+ // "api://56e7ee83-1cf6-4048-a664-c2a08955f825/user_impersonation"
+ };
+
+ TokenRequestContext requestContext = new TokenRequestContext(scopes: installerTenantResourceScopes);
+
+ 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 response = await credential.GetTokenAsync(requestContext, cancellationToken);
+ accessToken = response.Token;
+ }
+ 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 token = await credential.GetTokenAsync(requestContext, cancellationToken);
+ accessToken = token.Token;
+ }
+
+ 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);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/VirtualClient/VirtualClient.Main/GetAccessTokenCommand.cs b/src/VirtualClient/VirtualClient.Main/GetAccessTokenCommand.cs
new file mode 100644
index 0000000000..e984d0e7bd
--- /dev/null
+++ b/src/VirtualClient/VirtualClient.Main/GetAccessTokenCommand.cs
@@ -0,0 +1,40 @@
+// 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
+ {
+ ///
+ /// 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);
+ }
+
+ return base.ExecuteAsync(args, cancellationTokenSource);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/VirtualClient/VirtualClient.Main/Program.cs b/src/VirtualClient/VirtualClient.Main/Program.cs
index db50e303ed..1f458fe89e 100644
--- a/src/VirtualClient/VirtualClient.Main/Program.cs
+++ b/src/VirtualClient/VirtualClient.Main/Program.cs
@@ -325,6 +325,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,6 +411,23 @@ 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.")
+ {
+ // OPTIONAL
+ // -------------------------------------------------------------------
+ OptionFactory.CreateParametersOption(required: false),
+
+ // --key-vault
+ OptionFactory.CreateKeyVaultOption(required: false)
+ };
+
+ return getAccessTokenCommand;
+ }
+
private static Command CreateBootstrapSubcommand(DefaultSettings settings)
{
Command bootstrapCommand = new Command(
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
From ffd7423cd6cbc4af989f9f3e4c26ec2a3709544d Mon Sep 17 00:00:00 2001
From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com>
Date: Thu, 18 Dec 2025 12:44:48 -0800
Subject: [PATCH 02/28] Adding UT for AccessToken.
---
.../GetAccessTokenProfileTests.cs | 63 ++++
.../KeyVaultAccessTokenTests.cs | 322 ++++++++++++++++++
.../KeyVaultAccessToken.cs | 71 +++-
3 files changed, 441 insertions(+), 15 deletions(-)
create mode 100644 src/VirtualClient/VirtualClient.Actions.FunctionalTests/GetAccessTokenProfileTests.cs
create mode 100644 src/VirtualClient/VirtualClient.Dependencies.UnitTests/KeyVaultAccessTokenTests.cs
diff --git a/src/VirtualClient/VirtualClient.Actions.FunctionalTests/GetAccessTokenProfileTests.cs b/src/VirtualClient/VirtualClient.Actions.FunctionalTests/GetAccessTokenProfileTests.cs
new file mode 100644
index 0000000000..87818f841d
--- /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 async Task 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.Dependencies.UnitTests/KeyVaultAccessTokenTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/KeyVaultAccessTokenTests.cs
new file mode 100644
index 0000000000..426f796446
--- /dev/null
+++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/KeyVaultAccessTokenTests.cs
@@ -0,0 +1,322 @@
+// 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 Polly;
+ 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.IsTrue(mode == FileMode.Create);
+ Assert.IsTrue(access == FileAccess.ReadWrite);
+ Assert.IsTrue(share == FileShare.ReadWrite);
+ });
+
+ 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(offset, 0);
+ Assert.AreEqual(count, byteData.Length);
+ Assert.AreEqual(data, byteData);
+ });
+
+ 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 ExecuteAsyncWillCheckIfValidTokenIsGenerated(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), string.Empty);
+ }
+ }
+ }
+
+ 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()
+ {
+ 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/KeyVaultAccessToken.cs b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
index 51cc42cafd..4f3e9ca116 100644
--- a/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
+++ b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
@@ -10,6 +10,7 @@ namespace VirtualClient.Dependencies
using System.Text;
using System.Threading;
using System.Threading.Tasks;
+ using Azure;
using Azure.Core;
using Azure.Identity;
using Microsoft.Extensions.DependencyInjection;
@@ -78,8 +79,7 @@ protected override async Task InitializeAsync(EventContext telemetryContext, Can
? this.LogFolderName
: this.fileSystem.Directory.GetCurrentDirectory();
- this.AccessTokenPath = this.fileSystem.Path.GetFullPath(
- this.fileSystem.Path.Combine(directory, this.LogFileName));
+ this.AccessTokenPath = this.Combine(directory, this.LogFileName);
if (this.fileSystem.File.Exists(this.AccessTokenPath))
{
@@ -103,15 +103,7 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel
string accessToken = null;
if (!cancellationToken.IsCancellationRequested)
{
- string[] installerTenantResourceScopes = new string[]
- {
- new Uri(baseUri: new Uri(this.KeyVaultUri), relativeUri: ".default").ToString(),
- // Example of a specific scope:
- // "api://56e7ee83-1cf6-4048-a664-c2a08955f825/user_impersonation"
- };
-
- TokenRequestContext requestContext = new TokenRequestContext(scopes: installerTenantResourceScopes);
-
+ TokenRequestContext requestContext = this.GetTokenRequestContext();
try
{
// Attempt an interactive (browser-based) authentication first. On most Windows environments
@@ -124,8 +116,8 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel
TenantId = this.TenantId
});
- AccessToken response = await credential.GetTokenAsync(requestContext, cancellationToken);
- accessToken = response.Token;
+ accessToken = await this.AcquireInteractiveTokenAsync(credential, requestContext, cancellationToken);
+
}
catch (AuthenticationFailedException exc) when (exc.Message.Contains("Unable to open a web page"))
{
@@ -150,8 +142,7 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel
}
});
- AccessToken token = await credential.GetTokenAsync(requestContext, cancellationToken);
- accessToken = token.Token;
+ accessToken = await this.AcquireDeviceCodeTokenAsync(credential, requestContext, cancellationToken);
}
if (string.IsNullOrWhiteSpace(accessToken))
@@ -170,6 +161,8 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel
byte[] bytedata = Encoding.Default.GetBytes(accessToken);
fileStream.Write(bytedata, 0, bytedata.Length);
await fileStream.FlushAsync().ConfigureAwait(false);
+ fileStream.Close();
+ fileStream.Dispose();
this.Logger.LogTraceMessage($"Access token saved to file: {this.AccessTokenPath}");
}
}
@@ -178,5 +171,53 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel
Console.WriteLine(accessToken);
}
}
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ protected virtual async Task AcquireInteractiveTokenAsync(
+ TokenCredential credential,
+ TokenRequestContext requestContext,
+ CancellationToken cancellationToken)
+ {
+ AccessToken response = await credential.GetTokenAsync(requestContext, cancellationToken);
+ return response.Token;
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ protected virtual async Task AcquireDeviceCodeTokenAsync(
+ TokenCredential credential,
+ TokenRequestContext requestContext,
+ CancellationToken cancellationToken)
+ {
+ AccessToken response = await credential.GetTokenAsync(requestContext, cancellationToken);
+ return response.Token;
+ }
+
+ ///
+ ///
+ ///
+ ///
+ protected virtual TokenRequestContext GetTokenRequestContext()
+ {
+ string[] installerTenantResourceScopes = new string[]
+ {
+ new Uri(baseUri: new Uri(this.KeyVaultUri), relativeUri: ".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
From cc08bf05f5e16c9c120e0ba1a5a6eeb9a04c142d Mon Sep 17 00:00:00 2001
From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com>
Date: Thu, 18 Dec 2025 13:13:29 -0800
Subject: [PATCH 03/28] Clean up
---
.../KeyVaultAccessTokenTests.cs | 17 +++---
.../KeyVaultAccessToken.cs | 58 +++++++++----------
2 files changed, 36 insertions(+), 39 deletions(-)
diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/KeyVaultAccessTokenTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/KeyVaultAccessTokenTests.cs
index 426f796446..0e4a1ded68 100644
--- a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/KeyVaultAccessTokenTests.cs
+++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/KeyVaultAccessTokenTests.cs
@@ -13,7 +13,6 @@ namespace VirtualClient.Dependencies
using Azure.Identity;
using Moq;
using NUnit.Framework;
- using Polly;
using VirtualClient.Common.Telemetry;
[TestFixture]
@@ -132,9 +131,9 @@ public async Task ExecuteAsyncWillWriteTokenToFileWhenLogFileNameIsProvided(Plat
.Callback((string path, FileMode mode, FileAccess access, FileShare share) =>
{
Assert.AreEqual(expectedPath, path);
- Assert.IsTrue(mode == FileMode.Create);
- Assert.IsTrue(access == FileAccess.ReadWrite);
- Assert.IsTrue(share == FileShare.ReadWrite);
+ Assert.AreEqual(FileMode.Create, mode);
+ Assert.AreEqual(FileAccess.ReadWrite, access);
+ Assert.AreEqual(FileShare.ReadWrite, share);
});
mockFileStream
@@ -142,9 +141,9 @@ public async Task ExecuteAsyncWillWriteTokenToFileWhenLogFileNameIsProvided(Plat
.Callback((byte[] data, int offset, int count) =>
{
byte[] byteData = Encoding.Default.GetBytes(tokenContent);
- Assert.AreEqual(offset, 0);
- Assert.AreEqual(count, byteData.Length);
- Assert.AreEqual(data, byteData);
+ Assert.AreEqual(0, offset);
+ Assert.AreEqual(byteData.Length, count);
+ CollectionAssert.AreEqual(byteData, data);
});
using (TestKeyVaultAccessToken component = new TestKeyVaultAccessToken(this.mockFixture.Dependencies, this.CreateDefaultParameters()))
@@ -220,7 +219,7 @@ public async Task ExecuteAsyncWillUseDeviceLoginIfInteractiveFailsWithExactError
[TestCase(PlatformID.Win32NT, " ")]
[TestCase(PlatformID.Unix, "validToken")]
[TestCase(PlatformID.Win32NT, "validToken")]
- public void ExecuteAsyncWillCheckIfValidTokenIsGenerated(PlatformID platform, string token)
+ public void ExecuteAsyncThrowsErrorIfTokenIsNullOrWhitespace(PlatformID platform, string token)
{
this.mockFixture.Setup(platform);
@@ -234,7 +233,7 @@ public void ExecuteAsyncWillCheckIfValidTokenIsGenerated(PlatformID platform, st
}
else
{
- Assert.DoesNotThrowAsync(() => component.ExecuteAsync(CancellationToken.None), string.Empty);
+ Assert.DoesNotThrowAsync(() => component.ExecuteAsync(CancellationToken.None));
}
}
}
diff --git a/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
index 4f3e9ca116..567ac45282 100644
--- a/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
+++ b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
@@ -10,7 +10,6 @@ namespace VirtualClient.Dependencies
using System.Text;
using System.Threading;
using System.Threading.Tasks;
- using Azure;
using Azure.Core;
using Azure.Identity;
using Microsoft.Extensions.DependencyInjection;
@@ -19,8 +18,8 @@ namespace VirtualClient.Dependencies
using VirtualClient.Contracts;
///
- /// Virtual Client component that acquires an access token for an Azure Key Vault
- /// using interactive browser or device-code authentication.
+ /// 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
{
@@ -39,7 +38,7 @@ public KeyVaultAccessToken(IServiceCollection dependencies, IDictionary
- /// The Azure tenant ID used when requesting an access token for the Key Vault.
+ /// Gets the Azure tenant ID used to acquire an access token.
///
protected string TenantId
{
@@ -50,7 +49,7 @@ protected string TenantId
}
///
- /// The Azure Key Vault URI for which an access token will be requested.
+ /// Gets the Azure Key Vault URI for which the access token will be requested.
/// Example: https://anyvault.vault.azure.net/
///
protected string KeyVaultUri
@@ -62,14 +61,15 @@ protected string KeyVaultUri
}
///
- /// The full file path where the acquired access token will be written,
- /// when configured via / .
+ /// 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; }
///
- /// Initializes the component for execution, including resolving the access token
- /// output path and removing any existing token file if configured.
+ /// 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)
{
@@ -90,10 +90,9 @@ protected override async Task InitializeAsync(EventContext telemetryContext, Can
///
/// Acquires an access token for the configured Key Vault URI using Azure Identity.
- /// Attempts interactive browser authentication first and falls back to
- /// device-code authentication when a browser is not available.
- /// The access token can optionally be written to a file and is always
- /// written to the console output.
+ /// 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)
{
@@ -117,7 +116,6 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel
});
accessToken = await this.AcquireInteractiveTokenAsync(credential, requestContext, cancellationToken);
-
}
catch (AuthenticationFailedException exc) when (exc.Message.Contains("Unable to open a web page"))
{
@@ -161,8 +159,6 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel
byte[] bytedata = Encoding.Default.GetBytes(accessToken);
fileStream.Write(bytedata, 0, bytedata.Length);
await fileStream.FlushAsync().ConfigureAwait(false);
- fileStream.Close();
- fileStream.Dispose();
this.Logger.LogTraceMessage($"Access token saved to file: {this.AccessTokenPath}");
}
}
@@ -173,15 +169,15 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel
}
///
- ///
+ /// 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,
+ TokenCredential credential,
+ TokenRequestContext requestContext,
CancellationToken cancellationToken)
{
AccessToken response = await credential.GetTokenAsync(requestContext, cancellationToken);
@@ -189,12 +185,13 @@ protected virtual async Task AcquireInteractiveTokenAsync(
}
///
- ///
+ /// 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,
@@ -205,9 +202,10 @@ protected virtual async Task AcquireDeviceCodeTokenAsync(
}
///
- ///
+ /// 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[]
From 63af6a1332b3dce230b4a2dc34c50bc3b305c068 Mon Sep 17 00:00:00 2001
From: nchapagain001 <165215502+nchapagain001@users.noreply.github.com>
Date: Fri, 19 Dec 2025 12:56:42 -0800
Subject: [PATCH 04/28] Idea for endpoint parsing.
---
.../Identity/EndPointSettingsExtensions.cs | 302 ++++++++++++++++++
.../Identity/EndpointSettings.cs | 49 +++
2 files changed, 351 insertions(+)
create mode 100644 src/VirtualClient/VirtualClient.Core/Identity/EndPointSettingsExtensions.cs
create mode 100644 src/VirtualClient/VirtualClient.Core/Identity/EndpointSettings.cs
diff --git a/src/VirtualClient/VirtualClient.Core/Identity/EndPointSettingsExtensions.cs b/src/VirtualClient/VirtualClient.Core/Identity/EndPointSettingsExtensions.cs
new file mode 100644
index 0000000000..1dce8ad002
--- /dev/null
+++ b/src/VirtualClient/VirtualClient.Core/Identity/EndPointSettingsExtensions.cs
@@ -0,0 +1,302 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using VirtualClient.Contracts;
+
+namespace VirtualClient.Identity
+{
+ internal static class EndPointSettingsExtensions
+ {
+ internal const string AllowedPackageUri = "https://packages.virtualclient.microsoft.com";
+
+ ///
+ /// Returns true/false whether the value is a custom Virtual Client connection string
+ /// (e.g. EndpointUrl=https://any.blob.core.windows.net;ManagedIdentityId=307591a4-abb2-4559-af59-b47177d140cf).
+ ///
+ /// The value to evaluate.
+ /// True if the value is a custom Virtual Client connection string. False if not.
+ public static bool IsCustomConnectionString(string value)
+ {
+ bool isConnectionString = false;
+ StringComparison ignoreCase = StringComparison.OrdinalIgnoreCase;
+
+ if (value.Contains($"{ConnectionParameter.EndpointUrl}=", ignoreCase)
+ || value.Contains($"{ConnectionParameter.EventHubNamespace}=", ignoreCase)
+ || value.Contains($"{ConnectionParameter.ClientId}=", ignoreCase)
+ || value.Contains($"{ConnectionParameter.TenantId}=", ignoreCase)
+ || value.Contains($"{ConnectionParameter.ManagedIdentityId}=", ignoreCase)
+ || value.Contains($"{ConnectionParameter.CertificateIssuer}=", ignoreCase)
+ || value.Contains($"{ConnectionParameter.CertificateSubject}=", ignoreCase)
+ || value.Contains($"{ConnectionParameter.CertificateThumbprint}=", ignoreCase))
+ {
+ isConnectionString = true;
+ }
+
+ return isConnectionString;
+ }
+
+ ///
+ /// Returns true/false whether the value is a custom Virtual Client URI
+ /// (e.g. https://any.service.azure.com/?cid=307591a4-abb2-4559-af59-b47177d140cf&tid=985bbc17-E3a5-4fec-b0cb-40dbb8bc5959&crti=ABC&crts=any.service.com).
+ ///
+ /// The URI to evaluate.
+ /// True if the URI is a custom Virtual Client URI. False if not.
+ public static bool IsCustomUri(Uri endpointUri)
+ {
+ return Regex.IsMatch(
+ endpointUri.Query,
+ $"{UriParameter.CertificateIssuer}=|{UriParameter.CertificateSubject}=|{UriParameter.CertificateThumbprint}=|{UriParameter.ClientId}=|{UriParameter.ManagedIdentityId}=|{UriParameter.TenantId}=",
+ RegexOptions.IgnoreCase);
+ }
+
+ ///
+ /// Returns true/false whether the value is an Event Hub namespace access policy/connection string.
+ /// (e.g. Endpoint=sb://any.servicebus.windows.net/;SharedAccessKeyName=AnyAccessPolicy;SharedAccessKey=...).
+ ///
+ /// The value to evaluate.
+ /// True if the value is an Event Hub namespace connection string. False if not.
+ public static bool IsEventHubConnectionString(string value)
+ {
+ return Regex.IsMatch(
+ value,
+ $"{ConnectionParameter.Endpoint}=|{ConnectionParameter.SharedAccessKeyName}=",
+ RegexOptions.IgnoreCase);
+ }
+
+ ///
+ /// Returns true/false whether the value is a standard Storage Account connection string.
+ /// (e.g. DefaultEndpointsProtocol=https;AccountName=anystorage;EndpointSuffix=core.windows.net...).
+ ///
+ /// The value to evaluate.
+ /// True if the value is a Storage Account connection string. False if not.
+ public static bool IsStorageAccountConnectionString(string value)
+ {
+ return Regex.IsMatch(
+ value,
+ $"{ConnectionParameter.DefaultEndpointsProtocol}=|{ConnectionParameter.BlobEndpoint}=",
+ RegexOptions.IgnoreCase);
+ }
+
+ ///
+ /// Returns true/false whether the value is a standard Storage Account connection string.
+ /// (e.g. DefaultEndpointsProtocol=https;AccountName=anystorage;EndpointSuffix=core.windows.net...).
+ ///
+ /// The URI to evaluate.
+ /// True if the value is a Storage Account connection string. False if not.
+ public static bool IsStorageAccountSasUri(Uri endpointUri)
+ {
+ return Regex.IsMatch(endpointUri.Query, "sv=|se=|spr=|sig=", RegexOptions.IgnoreCase);
+ }
+
+ ///
+ /// Parses the subject name and issuer from the provided uri. If the uri does not contain the correctly formatted certificate subject name
+ /// and issuer information the method will return false, and keep the two out parameters as null.
+ /// Ex. https://vegaprod01proxyapi.azurewebsites.net?crti=issuerName&crts=certSubject
+ ///
+ /// The uri to attempt to parse the values from.
+ /// The issuer of the certificate.
+ /// The subject of the certificate.
+ /// True/False if the method was able to successfully parse both the subject name and the issuer of the certificate.
+ public static bool TryParseCertificateReference(Uri uri, out string issuer, out string subject)
+ {
+ string queryString = Uri.UnescapeDataString(uri.Query).Trim('?').Replace("&", ",,,");
+
+ IDictionary queryParameters = TextParsingExtensions.ParseDelimitedValues(queryString)?.ToDictionary(
+ entry => entry.Key,
+ entry => entry.Value?.ToString(),
+ StringComparer.OrdinalIgnoreCase);
+
+ return TryGetCertificateReferenceForUri(queryParameters, out issuer, out subject);
+ }
+
+ ///
+ /// 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.
+ ///
+ /// endpoint to verify and format
+ ///
+ public static string ValidateAndFormatPackageUri(string endpoint)
+ {
+ string packageUri = new Uri(AllowedPackageUri).Host;
+ return packageUri == endpoint ? $"https://{endpoint}" : endpoint;
+ }
+
+ internal static bool TryGetEndpointForConnection(IDictionary connectionParameters, out string endpoint)
+ {
+ bool endpointDefined = false;
+ endpoint = null;
+
+ if (connectionParameters?.Any() == true)
+ {
+ if ((connectionParameters.TryGetValue(ConnectionParameter.Endpoint, out endpoint) || connectionParameters.TryGetValue(ConnectionParameter.EndpointUrl, out endpoint))
+ && !string.IsNullOrWhiteSpace(endpoint))
+ {
+ endpointDefined = true;
+ }
+ }
+
+ return endpointDefined;
+ }
+
+ internal static bool TryGetCertificateReferenceForConnection(IDictionary uriParameters, out string certificateThumbPrint)
+ {
+ bool parametersDefined = false;
+ certificateThumbPrint = null;
+
+ if (uriParameters?.Any() == true)
+ {
+ if (uriParameters.TryGetValue(ConnectionParameter.CertificateThumbprint, out string thumbprint)
+ && !string.IsNullOrWhiteSpace(thumbprint))
+ {
+ certificateThumbPrint = thumbprint;
+ parametersDefined = true;
+ }
+ }
+
+ return parametersDefined;
+ }
+
+ internal static bool TryGetCertificateReferenceForConnection(IDictionary connectionParameters, out string certificateIssuer, out string certificateSubject)
+ {
+ bool parametersDefined = false;
+ certificateIssuer = null;
+ certificateSubject = null;
+
+ if (connectionParameters?.Any() == true)
+ {
+ if (connectionParameters.TryGetValue(ConnectionParameter.CertificateIssuer, out string issuer)
+ && connectionParameters.TryGetValue(ConnectionParameter.CertificateSubject, out string subject)
+ && !string.IsNullOrWhiteSpace(issuer)
+ && !string.IsNullOrWhiteSpace(subject))
+ {
+ certificateIssuer = issuer;
+ certificateSubject = subject;
+ parametersDefined = true;
+ }
+ }
+
+ return parametersDefined;
+ }
+
+ internal static bool TryGetManagedIdentityReferenceForConnection(IDictionary connectionParameters, out string managedIdentityId)
+ {
+ bool parametersDefined = false;
+ managedIdentityId = null;
+
+ if (connectionParameters?.Any() == true)
+ {
+ if (connectionParameters.TryGetValue(ConnectionParameter.ManagedIdentityId, out string managedIdentityClientId)
+ && !string.IsNullOrWhiteSpace(managedIdentityClientId))
+ {
+ managedIdentityId = managedIdentityClientId;
+ parametersDefined = true;
+ }
+ }
+
+ return parametersDefined;
+ }
+
+ internal static bool TryGetMicrosoftEntraReferenceForConnection(IDictionary connectionParameters, out string clientId, out string tenantId)
+ {
+ bool parametersDefined = false;
+ clientId = null;
+ tenantId = null;
+
+ if (connectionParameters?.Any() == true)
+ {
+ if (connectionParameters.TryGetValue(ConnectionParameter.ClientId, out string microsoftEntraClientId)
+ && connectionParameters.TryGetValue(ConnectionParameter.TenantId, out string microsoftEntraTenantId)
+ && !string.IsNullOrWhiteSpace(microsoftEntraClientId)
+ && !string.IsNullOrWhiteSpace(microsoftEntraTenantId))
+ {
+ clientId = microsoftEntraClientId;
+ tenantId = microsoftEntraTenantId;
+ parametersDefined = true;
+ }
+ }
+
+ return parametersDefined;
+ }
+
+ internal static bool TryGetCertificateReferenceForUri(IDictionary uriParameters, out string certificateThumbPrint)
+ {
+ bool parametersDefined = false;
+ certificateThumbPrint = null;
+
+ if (uriParameters?.Any() == true)
+ {
+ if (uriParameters.TryGetValue(UriParameter.CertificateThumbprint, out string thumbprint)
+ && !string.IsNullOrWhiteSpace(thumbprint))
+ {
+ certificateThumbPrint = thumbprint;
+ parametersDefined = true;
+ }
+ }
+
+ return parametersDefined;
+ }
+
+ internal static bool TryGetCertificateReferenceForUri(IDictionary uriParameters, out string certificateIssuer, out string certificateSubject)
+ {
+ bool parametersDefined = false;
+ certificateIssuer = null;
+ certificateSubject = null;
+
+ if (uriParameters?.Any() == true)
+ {
+ if (uriParameters.TryGetValue(UriParameter.CertificateIssuer, out string issuer)
+ && uriParameters.TryGetValue(UriParameter.CertificateSubject, out string subject)
+ && !string.IsNullOrWhiteSpace(issuer)
+ && !string.IsNullOrWhiteSpace(subject))
+ {
+ certificateIssuer = issuer;
+ certificateSubject = subject;
+ parametersDefined = true;
+ }
+ }
+
+ return parametersDefined;
+ }
+
+ internal static bool TryGetManagedIdentityReferenceForUri(IDictionary uriParameters, out string managedIdentityId)
+ {
+ bool parametersDefined = false;
+ managedIdentityId = null;
+
+ if (uriParameters?.Any() == true)
+ {
+ if (uriParameters.TryGetValue(UriParameter.ManagedIdentityId, out string managedIdentityClientId)
+ && !string.IsNullOrWhiteSpace(managedIdentityClientId))
+ {
+ managedIdentityId = managedIdentityClientId;
+ parametersDefined = true;
+ }
+ }
+
+ return parametersDefined;
+ }
+
+ internal static bool TryGetMicrosoftEntraReferenceForUri(IDictionary uriParameters, out string clientId, out string tenantId)
+ {
+ bool parametersDefined = false;
+ clientId = null;
+ tenantId = null;
+
+ if (uriParameters?.Any() == true)
+ {
+ if (uriParameters.TryGetValue(UriParameter.ClientId, out string microsoftEntraClientId)
+ && uriParameters.TryGetValue(UriParameter.TenantId, out string microsoftEntraTenantId)
+ && !string.IsNullOrWhiteSpace(microsoftEntraClientId)
+ && !string.IsNullOrWhiteSpace(microsoftEntraTenantId))
+ {
+ clientId = microsoftEntraClientId;
+ tenantId = microsoftEntraTenantId;
+ parametersDefined = true;
+ }
+ }
+
+ return parametersDefined;
+ }
+ }
+}
diff --git a/src/VirtualClient/VirtualClient.Core/Identity/EndpointSettings.cs b/src/VirtualClient/VirtualClient.Core/Identity/EndpointSettings.cs
new file mode 100644
index 0000000000..de4a555ab0
--- /dev/null
+++ b/src/VirtualClient/VirtualClient.Core/Identity/EndpointSettings.cs
@@ -0,0 +1,49 @@
+namespace VirtualClient.Identity
+{
+ using System;
+ using VirtualClient.Common.Extensions;
+
+ internal class EndpointSettings
+ {
+ private static readonly char[] TrimChars = { '\'', '"', ' ' };
+
+ public EndpointSettings(string endpoint)
+ {
+ endpoint.ThrowIfNullOrWhiteSpace(nameof(endpoint));
+
+ endpoint = EndPointSettingsExtensions.ValidateAndFormatPackageUri(endpoint);
+ string argumentValue = endpoint.Trim(EndpointSettings.TrimChars);
+
+ // this.IsBlobStoreConnectionString = EndPointSettingsExtensions.IsBlobStoreConnectionString(argumentValue);
+
+ // this.EndPoint = new Uri(endpoint);
+ this.IsCustomConnectionString = EndPointSettingsExtensions.IsCustomConnectionString(endpoint);
+
+ this.IsEventHubConnectionString = EndPointSettingsExtensions.IsEventHubConnectionString(endpoint);
+ // this.IsKeyVaultConnectionString = EndPointSettingsExtensions.IsKeyVaultConnectionString(endpoint);
+
+ }
+
+ public Uri EndPoint { get; set; }
+
+ public bool IsCustomConnectionString { get; set; }
+
+ public bool IsBlobStoreConnectionString { get; set; }
+
+ public bool IsEventHubConnectionString { get; set; }
+
+ public bool IsKeyVaultConnectionString { get; set; }
+
+ public string TenantId { get; set; }
+
+ public string ClientId { get; set; }
+
+ public string ManagedIdentityId { get; set; }
+
+ public string CertificateThumbprint { get; set; }
+
+ public string CertificateSubjectName { get; set; }
+
+ public string CertificateIssuerName { get; set; }
+ }
+}
From 910b9c9c35124f773b450fb7f6ad23eefe636e17 Mon Sep 17 00:00:00 2001
From: Nirjan Chapagain
Date: Sat, 10 Jan 2026 10:45:23 -0800
Subject: [PATCH 05/28] saving existing work
---
.../DependencyKeyVaultStore.cs | 15 +-
.../Identity/AccessTokenCredential.cs | 58 +++++
.../CertificateInstallation.cs | 240 ++++++++++++++++++
.../profiles/INSTALL-CERTIFICATES.json | 22 ++
4 files changed, 332 insertions(+), 3 deletions(-)
create mode 100644 src/VirtualClient/VirtualClient.Core/Identity/AccessTokenCredential.cs
create mode 100644 src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
create mode 100644 src/VirtualClient/VirtualClient.Main/profiles/INSTALL-CERTIFICATES.json
diff --git a/src/VirtualClient/VirtualClient.Contracts/DependencyKeyVaultStore.cs b/src/VirtualClient/VirtualClient.Contracts/DependencyKeyVaultStore.cs
index 3f20d96445..ca97281b15 100644
--- a/src/VirtualClient/VirtualClient.Contracts/DependencyKeyVaultStore.cs
+++ b/src/VirtualClient/VirtualClient.Contracts/DependencyKeyVaultStore.cs
@@ -16,26 +16,30 @@ public class DependencyKeyVaultStore : DependencyStore
/// Initializes an instance of the class.
///
/// The name of the KeyVault store (e.g. KeyVault).
+ /// The Microsoft Entra tenant/directory ID.
/// The URI/SAS for the target Key Vault.
- public DependencyKeyVaultStore(string storeName, Uri endpointUri)
+ public DependencyKeyVaultStore(string storeName, string tenantId, Uri endpointUri)
: base(storeName, DependencyStore.StoreTypeAzureKeyVault)
{
endpointUri.ThrowIfNull(nameof(endpointUri));
this.EndpointUri = endpointUri;
this.KeyVaultNameSpace = endpointUri.Host;
+ this.TenantId = tenantId;
}
///
/// Initializes an instance of the class.
///
/// The name of the KeyVault store (e.g. KeyVault).
+ /// The Microsoft Entra tenant/directory ID.
/// The URI/SAS for the target Key Vault.
/// An identity token credential to use for authentication against the Key Vault.
- public DependencyKeyVaultStore(string storeName, Uri endpointUri, TokenCredential credentials)
- : this(storeName, endpointUri)
+ public DependencyKeyVaultStore(string storeName, string tenantId, Uri endpointUri, TokenCredential credentials)
+ : this(storeName, tenantId, endpointUri)
{
credentials.ThrowIfNull(nameof(credentials));
this.Credentials = credentials;
+ this.TenantId = tenantId;
}
///
@@ -48,6 +52,11 @@ public DependencyKeyVaultStore(string storeName, Uri endpointUri, TokenCredentia
///
public string KeyVaultNameSpace { get; }
+ ///
+ /// The Key Vault tenant ID.
+ ///
+ public string TenantId { get; }
+
///
/// An identity token credential to use for authentication against the Key vault.
///
diff --git a/src/VirtualClient/VirtualClient.Core/Identity/AccessTokenCredential.cs b/src/VirtualClient/VirtualClient.Core/Identity/AccessTokenCredential.cs
new file mode 100644
index 0000000000..8dbf2e87d6
--- /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 credential provider that will be used to get access tokens.
+ ///
+ 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.Dependencies/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
new file mode 100644
index 0000000000..273ca845de
--- /dev/null
+++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
@@ -0,0 +1,240 @@
+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.CodeAnalysis;
+ 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 acquires an Azure access token for the specified Key Vault
+ /// using interactive browser authentication with a device-code fallback.
+ ///
+ public class CertificateInstallation : 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 CertificateInstallation(IServiceCollection dependencies, IDictionary parameters = null)
+ : base(dependencies, parameters)
+ {
+ this.fileSystem = this.Dependencies.GetService();
+ this.fileSystem.ThrowIfNull(nameof(this.fileSystem));
+ }
+
+ ///
+ /// Gets the Azure tenant ID used to acquire an access token.
+ ///
+ protected string TenantId
+ {
+ get
+ {
+ return this.Parameters.GetValue(nameof(this.TenantId));
+ }
+ }
+
+ ///
+ /// Gets the Azure Key Vault URI for which the access token will be requested.
+ /// Example: https://anyvault.vault.azure.net/
+ ///
+ protected string KeyVaultUri
+ {
+ get
+ {
+ return this.Parameters.GetValue(nameof(this.KeyVaultUri));
+ }
+ }
+
+ ///
+ /// The name of the certificate to be retrieved
+ ///
+ protected string CertificateName
+ {
+ get
+ {
+ return this.Parameters.GetValue(nameof(this.KeyVaultUri));
+ }
+ }
+
+ ///
+ /// 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 = await this.GetKeyVaultManager(cancellationToken);
+ X509Certificate2 certificate = await keyVault.GetCertificateAsync(this.CertificateName, cancellationToken);
+
+ 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/
+ //
+ // User = read, write, execute
+ // Group = read, write, execute
+ // Other = read, write, execute
+ using (IProcessProxy process = 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.");
+ }
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ protected async Task GetKeyVaultManager(CancellationToken cancellationToken)
+ {
+ IKeyVaultManager keyVaultManager = this.Dependencies.GetService();
+ keyVaultManager.ThrowIfNull(nameof(keyVaultManager));
+
+ // need better if condition.
+ if (keyVaultManager.StoreDescription != null)
+ {
+ return keyVaultManager;
+ }
+ else if (!string.IsNullOrWhiteSpace(this.LogFileName))
+ {
+ string directory = !string.IsNullOrWhiteSpace(this.LogFolderName)
+ ? this.LogFolderName
+ : this.fileSystem.Directory.GetCurrentDirectory();
+
+ string accessTokenPath = this.Combine(directory, this.LogFileName);
+
+ string keyVaultUri = string.IsNullOrWhiteSpace(this.KeyVaultUri)
+ ? ((DependencyKeyVaultStore)keyVaultManager.StoreDescription).EndpointUri.ToString()
+ : this.KeyVaultUri;
+
+ string tenantId = string.IsNullOrWhiteSpace(this.TenantId)
+ ? ((DependencyKeyVaultStore)keyVaultManager.StoreDescription).TenantId
+ : this.TenantId;
+
+ keyVaultUri.ThrowIfNullOrWhiteSpace(nameof(this.KeyVaultUri));
+ tenantId.ThrowIfNullOrWhiteSpace(nameof(this.TenantId));
+
+ string token_ = await this.fileSystem.File.ReadAllTextAsync(accessTokenPath, cancellationToken).ConfigureAwait(false);
+ AccessTokenCredential tokenCredential = new AccessTokenCredential(token_);
+
+ DependencyKeyVaultStore dependencyKeyVault = new DependencyKeyVaultStore(DependencyStore.KeyVault, this.TenantId, new Uri(this.KeyVaultUri), tokenCredential);
+ return new KeyVaultManager(dependencyKeyVault);
+ }
+ else
+ {
+ throw new InvalidOperationException($"The Key Vault manager has not been properly initialized. The '{nameof(this.LogFileName)}' parameter must be provided to read the access token from file.");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-CERTIFICATES.json b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-CERTIFICATES.json
new file mode 100644
index 0000000000..e3f4fc4424
--- /dev/null
+++ b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-CERTIFICATES.json
@@ -0,0 +1,22 @@
+{
+ "Description": "Installs certificate from a Azure Key Vault.",
+ "Parameters": {
+ "AccessToken": "{access token}",
+ "KeyVaultUri": "https://yourkeyvault.vault.azure.net/",
+ "CertificateName": "cert-01-name",
+ "CertificatePassword": "",
+ "TenantId": ""
+ },
+ "Dependencies": [
+ {
+ "Type": "CertificateInstallation",
+ "Parameters": {
+ "Scenario": "InstallCertificate",
+ "AccessToken": "$.Parameters.AccessToken",
+ "TenantId": "$.Parameters.TenantId",
+ "KeyVaultUri": "$.Parameters.KeyVaultUri",
+ "CertificateName": "$.Parameters.CertificateName"
+ }
+ }
+ ]
+}
\ No newline at end of file
From f113108e7e9dbe2cd74925ee87b9603df1842880 Mon Sep 17 00:00:00 2001
From: Nirjan Chapagain
Date: Sat, 10 Jan 2026 13:59:58 -0800
Subject: [PATCH 06/28] get-token works with tenantId praameter and tid from
key vault uri
---
.../GetAccessTokenProfileTests.cs | 2 +-
.../DependencyKeyVaultStore.cs | 17 ++-----
.../VirtualClient.Core/EndpointUtility.cs | 51 +++++++++++++++++++
.../CertificateInstallation.cs | 18 +++----
.../KeyVaultAccessToken.cs | 35 ++++++-------
5 files changed, 80 insertions(+), 43 deletions(-)
diff --git a/src/VirtualClient/VirtualClient.Actions.FunctionalTests/GetAccessTokenProfileTests.cs b/src/VirtualClient/VirtualClient.Actions.FunctionalTests/GetAccessTokenProfileTests.cs
index 87818f841d..13b099c53a 100644
--- a/src/VirtualClient/VirtualClient.Actions.FunctionalTests/GetAccessTokenProfileTests.cs
+++ b/src/VirtualClient/VirtualClient.Actions.FunctionalTests/GetAccessTokenProfileTests.cs
@@ -41,7 +41,7 @@ public void GetAccessTokenProfileParametersAreInlinedCorrectly(string profile, P
[Test]
[TestCase("GET-ACCESS-TOKEN.json", PlatformID.Unix)]
[TestCase("GET-ACCESS-TOKEN.json", PlatformID.Win32NT)]
- public async Task GetAccessTokenProfileParametersAreAvailable(string profile, PlatformID platform)
+ public void GetAccessTokenProfileParametersAreAvailable(string profile, PlatformID platform)
{
this.dependencyFixture.Setup(platform);
diff --git a/src/VirtualClient/VirtualClient.Contracts/DependencyKeyVaultStore.cs b/src/VirtualClient/VirtualClient.Contracts/DependencyKeyVaultStore.cs
index ca97281b15..959431724d 100644
--- a/src/VirtualClient/VirtualClient.Contracts/DependencyKeyVaultStore.cs
+++ b/src/VirtualClient/VirtualClient.Contracts/DependencyKeyVaultStore.cs
@@ -16,30 +16,26 @@ public class DependencyKeyVaultStore : DependencyStore
/// Initializes an instance of the class.
///
/// The name of the KeyVault store (e.g. KeyVault).
- /// The Microsoft Entra tenant/directory ID.
/// The URI/SAS for the target Key Vault.
- public DependencyKeyVaultStore(string storeName, string tenantId, Uri endpointUri)
+ public DependencyKeyVaultStore(string storeName, Uri endpointUri)
: base(storeName, DependencyStore.StoreTypeAzureKeyVault)
{
endpointUri.ThrowIfNull(nameof(endpointUri));
this.EndpointUri = endpointUri;
this.KeyVaultNameSpace = endpointUri.Host;
- this.TenantId = tenantId;
}
///
/// Initializes an instance of the class.
///
/// The name of the KeyVault store (e.g. KeyVault).
- /// The Microsoft Entra tenant/directory ID.
/// The URI/SAS for the target Key Vault.
/// An identity token credential to use for authentication against the Key Vault.
- public DependencyKeyVaultStore(string storeName, string tenantId, Uri endpointUri, TokenCredential credentials)
- : this(storeName, tenantId, endpointUri)
+ public DependencyKeyVaultStore(string storeName, Uri endpointUri, TokenCredential credentials)
+ : this(storeName, endpointUri)
{
credentials.ThrowIfNull(nameof(credentials));
this.Credentials = credentials;
- this.TenantId = tenantId;
}
///
@@ -52,14 +48,9 @@ public DependencyKeyVaultStore(string storeName, string tenantId, Uri endpointUr
///
public string KeyVaultNameSpace { get; }
- ///
- /// The Key Vault tenant ID.
- ///
- public string TenantId { get; }
-
///
/// An identity token credential to use for authentication against the Key vault.
///
public TokenCredential Credentials { get; }
}
-}
+}
\ No newline at end of file
diff --git a/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs b/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs
index 9294acffa1..86803e9c48 100644
--- a/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs
+++ b/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs
@@ -398,6 +398,39 @@ 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 TryParseMicrosoftEntraReference(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);
+
+ bool parametersDefined = false;
+ tenantId = null;
+
+ if (queryParameters?.Any() == true)
+ {
+ if (queryParameters.TryGetValue(UriParameter.TenantId, out string microsoftEntraTenantId)
+ && !string.IsNullOrWhiteSpace(microsoftEntraTenantId))
+ {
+ tenantId = microsoftEntraTenantId;
+ parametersDefined = true;
+ }
+ }
+
+ return parametersDefined;
+ }
+
///
/// 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 +1325,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.Dependencies/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
index 273ca845de..c0b3f86f8e 100644
--- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
+++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
@@ -20,7 +20,9 @@
///
public class CertificateInstallation : VirtualClientComponent
{
+ private ISystemManagement systemManagement;
private IFileSystem fileSystem;
+ private ProcessManager processManager;
///
/// Initializes a new instance of the class.
@@ -30,8 +32,9 @@ public class CertificateInstallation : VirtualClientComponent
public CertificateInstallation(IServiceCollection dependencies, IDictionary parameters = null)
: base(dependencies, parameters)
{
- this.fileSystem = this.Dependencies.GetService();
- this.fileSystem.ThrowIfNull(nameof(this.fileSystem));
+ this.systemManagement = dependencies.GetService();
+ this.fileSystem = this.systemManagement.FileSystem;
+ this.processManager = this.systemManagement.ProcessManager;
}
///
@@ -176,8 +179,8 @@ await this.fileSystem.File.WriteAllBytesAsync(
//
// User = read, write, execute
// Group = read, write, execute
- // Other = read, write, execute
- using (IProcessProxy process = processManager.CreateProcess("chmod", $"-R 777 {certificateDirectory}"))
+ // Other = read, write, execute+
+ using (IProcessProxy process = this.processManager.CreateProcess("chmod", $"-R 777 {certificateDirectory}"))
{
await process.StartAndWaitAsync(cancellationToken);
process.ThrowIfErrored();
@@ -217,18 +220,13 @@ protected async Task GetKeyVaultManager(CancellationToken canc
string keyVaultUri = string.IsNullOrWhiteSpace(this.KeyVaultUri)
? ((DependencyKeyVaultStore)keyVaultManager.StoreDescription).EndpointUri.ToString()
: this.KeyVaultUri;
-
- string tenantId = string.IsNullOrWhiteSpace(this.TenantId)
- ? ((DependencyKeyVaultStore)keyVaultManager.StoreDescription).TenantId
- : this.TenantId;
keyVaultUri.ThrowIfNullOrWhiteSpace(nameof(this.KeyVaultUri));
- tenantId.ThrowIfNullOrWhiteSpace(nameof(this.TenantId));
string token_ = await this.fileSystem.File.ReadAllTextAsync(accessTokenPath, cancellationToken).ConfigureAwait(false);
AccessTokenCredential tokenCredential = new AccessTokenCredential(token_);
- DependencyKeyVaultStore dependencyKeyVault = new DependencyKeyVaultStore(DependencyStore.KeyVault, this.TenantId, new Uri(this.KeyVaultUri), tokenCredential);
+ DependencyKeyVaultStore dependencyKeyVault = new DependencyKeyVaultStore(DependencyStore.KeyVault, new Uri(this.KeyVaultUri), tokenCredential);
return new KeyVaultManager(dependencyKeyVault);
}
else
diff --git a/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
index 567ac45282..d19c8f2c5d 100644
--- a/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
+++ b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
@@ -38,27 +38,15 @@ public KeyVaultAccessToken(IServiceCollection dependencies, IDictionary
- /// Gets the Azure tenant ID used to acquire an access token.
+ /// Gets the Azure Key Vault URI for which the access token will be requested.
+ /// Example: https://anyvault.vault.azure.net/
///
- protected string TenantId
- {
- get
- {
- return this.Parameters.GetValue(nameof(this.TenantId));
- }
- }
+ protected Uri KeyVaultUri { get; set; }
///
- /// Gets the Azure Key Vault URI for which the access token will be requested.
- /// Example: https://anyvault.vault.azure.net/
+ /// Gets the Azure tenant ID used to acquire an access token.
///
- protected string KeyVaultUri
- {
- get
- {
- return this.Parameters.GetValue(nameof(this.KeyVaultUri));
- }
- }
+ 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.
@@ -96,7 +84,16 @@ protected override async Task InitializeAsync(EventContext telemetryContext, Can
///
protected override async Task ExecuteAsync(EventContext telemetryContext, CancellationToken cancellationToken)
{
- this.KeyVaultUri.ThrowIfNullOrWhiteSpace(nameof(this.KeyVaultUri));
+ this.KeyVaultUri = new Uri(this.Parameters.GetValue(nameof(this.KeyVaultUri)));
+ this.KeyVaultUri.ThrowIfNull(nameof(this.KeyVaultUri));
+
+ this.TenantId = this.Parameters.GetValue(nameof(this.TenantId));
+ if (string.IsNullOrWhiteSpace(this.TenantId))
+ {
+ EndpointUtility.TryParseMicrosoftEntraReference(this.KeyVaultUri, out string tenant);
+ this.TenantId = tenant;
+ }
+
this.TenantId.ThrowIfNullOrWhiteSpace(nameof(this.TenantId));
string accessToken = null;
@@ -210,7 +207,7 @@ protected virtual TokenRequestContext GetTokenRequestContext()
{
string[] installerTenantResourceScopes = new string[]
{
- new Uri(baseUri: new Uri(this.KeyVaultUri), relativeUri: ".default").ToString(),
+ new Uri(baseUri: this.KeyVaultUri, relativeUri: ".default").ToString(),
// Example of a specific scope:
// "api://56e7ee83-1cf6-4048-a664-c2a08955f825/user_impersonation"
};
From c4c452cdc523f9e4daa5a73cdf9d2a0b27d17296 Mon Sep 17 00:00:00 2001
From: Nirjan Chapagain
Date: Sat, 10 Jan 2026 14:08:31 -0800
Subject: [PATCH 07/28] Foo
---
.../VirtualClient.Core/EndpointUtility.cs | 19 +++----------------
.../KeyVaultAccessToken.cs | 2 +-
2 files changed, 4 insertions(+), 17 deletions(-)
diff --git a/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs b/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs
index 86803e9c48..dff6394fb1 100644
--- a/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs
+++ b/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs
@@ -406,7 +406,7 @@ public static bool TryParseCertificateReference(Uri uri, out string issuer, out
/// 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 TryParseMicrosoftEntraReference(Uri uri, out string tenantId)
+ public static bool TryParseMicrosoftEntraTenantIdReference(Uri uri, out string tenantId)
{
string queryString = Uri.UnescapeDataString(uri.Query).Trim('?').Replace("&", ",,,");
@@ -415,20 +415,7 @@ public static bool TryParseMicrosoftEntraReference(Uri uri, out string tenantId)
entry => entry.Value?.ToString(),
StringComparer.OrdinalIgnoreCase);
- bool parametersDefined = false;
- tenantId = null;
-
- if (queryParameters?.Any() == true)
- {
- if (queryParameters.TryGetValue(UriParameter.TenantId, out string microsoftEntraTenantId)
- && !string.IsNullOrWhiteSpace(microsoftEntraTenantId))
- {
- tenantId = microsoftEntraTenantId;
- parametersDefined = true;
- }
- }
-
- return parametersDefined;
+ return TryGetMicrosoftEntraTenantId(queryParameters, out tenantId);
}
///
@@ -1326,7 +1313,7 @@ private static bool TryGetMicrosoftEntraReferenceForUri(IDictionary uriParameters, out string tenantId)
+ private static bool TryGetMicrosoftEntraTenantId(IDictionary uriParameters, out string tenantId)
{
bool parametersDefined = false;
tenantId = null;
diff --git a/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
index d19c8f2c5d..166026fafe 100644
--- a/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
+++ b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
@@ -90,7 +90,7 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel
this.TenantId = this.Parameters.GetValue(nameof(this.TenantId));
if (string.IsNullOrWhiteSpace(this.TenantId))
{
- EndpointUtility.TryParseMicrosoftEntraReference(this.KeyVaultUri, out string tenant);
+ EndpointUtility.TryParseMicrosoftEntraTenantIdReference(this.KeyVaultUri, out string tenant);
this.TenantId = tenant;
}
From 4121ce89222c6b4f3cffb853e1906e0ff070ee52 Mon Sep 17 00:00:00 2001
From: Nirjan Chapagain
Date: Thu, 15 Jan 2026 08:39:52 -0800
Subject: [PATCH 08/28] Working version
---
.../KeyVaultManagerTests.cs | 2 +-
.../VirtualClient.Core/IKeyVaultManager.cs | 3 +
.../VirtualClient.Core/KeyVaultManager.cs | 60 +++++++++++--------
.../CertificateInstallation.cs | 57 ++++++++++++------
4 files changed, 78 insertions(+), 44 deletions(-)
diff --git a/src/VirtualClient/VirtualClient.Core.UnitTests/KeyVaultManagerTests.cs b/src/VirtualClient/VirtualClient.Core.UnitTests/KeyVaultManagerTests.cs
index c3c6c6ac40..aefc00d3a4 100644
--- a/src/VirtualClient/VirtualClient.Core.UnitTests/KeyVaultManagerTests.cs
+++ b/src/VirtualClient/VirtualClient.Core.UnitTests/KeyVaultManagerTests.cs
@@ -119,7 +119,7 @@ public async Task KeyVaultManagerReturnsExpectedKey()
[TestCase(false)]
public async Task KeyVaultManagerReturnsExpectedCertificate(bool retrieveWithPrivateKey)
{
- var result = await this.keyVaultManager.GetCertificateAsync("mycert", CancellationToken.None, "https://myvault.vault.azure.net/", retrieveWithPrivateKey);
+ var result = await this.keyVaultManager.GetCertificateAsync(PlatformID.Unix, "mycert", CancellationToken.None, "https://myvault.vault.azure.net/", retrieveWithPrivateKey);
Assert.IsNotNull(result);
if (retrieveWithPrivateKey)
{
diff --git a/src/VirtualClient/VirtualClient.Core/IKeyVaultManager.cs b/src/VirtualClient/VirtualClient.Core/IKeyVaultManager.cs
index 4a4d19329a..292347405d 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;
@@ -60,6 +61,7 @@ Task GetKeyAsync(
///
/// Retrieves a certificate from the Azure Key Vault.
///
+ /// The operating system platform (e.g. Windows, Linux).
/// The name of the certificate to be retrieved
/// A token that can be used to cancel the operation.
/// The URI of the Azure Key Vault.
@@ -72,6 +74,7 @@ Task GetKeyAsync(
/// Thrown if the certificate is not found, access is denied, or another error occurs.
///
Task GetCertificateAsync(
+ PlatformID platform,
string certName,
CancellationToken cancellationToken,
string keyVaultUri = null,
diff --git a/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs b/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs
index de1b82a6a6..8676792d8e 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.
@@ -190,6 +189,7 @@ public async Task GetKeyAsync(
///
/// Retrieves a certificate from the Azure Key Vault.
///
+ ///
/// The name of the certificate to be retrieved
/// A token that can be used to cancel the operation.
/// The URI of the Azure Key Vault.
@@ -202,13 +202,13 @@ public async Task GetKeyAsync(
/// Thrown if the certificate is not found, access is denied, or another error occurs.
///
public async Task GetCertificateAsync(
+ PlatformID platform,
string certName,
CancellationToken cancellationToken,
string keyVaultUri = null,
bool retrieveWithPrivateKey = false,
IAsyncPolicy retryPolicy = null)
{
- this.ValidateKeyVaultStore();
this.StoreDescription.ThrowIfNull(nameof(this.StoreDescription));
certName.ThrowIfNullOrWhiteSpace(nameof(certName), "The certificate name cannot be null or empty.");
@@ -217,37 +217,47 @@ public async Task GetCertificateAsync(
? new Uri(keyVaultUri)
: ((DependencyKeyVaultStore)this.StoreDescription).EndpointUri;
- CertificateClient client = this.CreateCertificateClient(vaultUri, ((DependencyKeyVaultStore)this.StoreDescription).Credentials);
-
try
{
- return await (retryPolicy ?? KeyVaultManager.DefaultRetryPolicy).ExecuteAsync(async () =>
+ KeyVaultSecret keyVaultSecret = await (retryPolicy ?? KeyVaultManager.DefaultRetryPolicy).ExecuteAsync(async () =>
{
- // Get the full certificate with private key (PFX) if requested
- if (retrieveWithPrivateKey)
- {
- X509Certificate2 privateKeyCert = await client
- .DownloadCertificateAsync(certName, cancellationToken: cancellationToken)
- .ConfigureAwait(false);
+ SecretClient secretsClient = new SecretClient(vaultUri, ((DependencyKeyVaultStore)this.StoreDescription).Credentials);
+ Response response = await secretsClient.GetSecretAsync(certName, version: null, cancellationToken);
- if (privateKeyCert is null || !privateKeyCert.HasPrivateKey)
- {
- throw new DependencyException("Failed to retrieve certificate content with private key.");
- }
+ return response.Value;
+ }).ConfigureAwait(false);
+
+ byte[] privateKeyBytes = Convert.FromBase64String(keyVaultSecret.Value);
+ X509Certificate2 certificate = null;
+
+ var keyStorageFlags = X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet;
- return privateKeyCert;
- }
- else
- {
- // If private key not needed, load cert from PublicBytes
- KeyVaultCertificateWithPolicy cert = await client.GetCertificateAsync(certName, cancellationToken: cancellationToken);
#if NET9_0_OR_GREATER
- return X509CertificateLoader.LoadCertificate(cert.Cer);
+ if (platform == PlatformID.Unix)
+ {
+ certificate = X509CertificateLoader.LoadPkcs12(privateKeyBytes, null, X509KeyStorageFlags.PersistKeySet);
+ }
+ else if (platform == PlatformID.Win32NT)
+ {
+ certificate = X509CertificateLoader.LoadPkcs12(privateKeyBytes, null, keyStorageFlags);
+ }
#elif NET8_0_OR_GREATER
- return new X509Certificate2(cert.Cer);
+ if (platform == PlatformID.Unix)
+ {
+ certificate = new X509Certificate2(privateKeyBytes, (string)null, X509KeyStorageFlags.PersistKeySet);
+ }
+ else if (platform == PlatformID.Win32NT)
+ {
+ certificate = new X509Certificate2(privateKeyBytes, (string)null, keyStorageFlags);
+ }
#endif
- }
- }).ConfigureAwait(false);
+
+ if (certificate is null || !certificate.HasPrivateKey)
+ {
+ throw new DependencyException("Failed to retrieve certificate content with private key.");
+ }
+
+ return certificate;
}
catch (RequestFailedException ex) when (ex.Status == (int)HttpStatusCode.Forbidden)
{
diff --git a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
index c0b3f86f8e..453eb5fa22 100644
--- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
+++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
@@ -67,7 +67,39 @@ protected string CertificateName
{
get
{
- return this.Parameters.GetValue(nameof(this.KeyVaultUri));
+ return this.Parameters.GetValue(nameof(this.CertificateName));
+ }
+ }
+
+ ///
+ /// Gets the access token used to authenticate with Azure services.
+ ///
+ protected string AccessToken { get; set; }
+
+ ///
+ /// Gets the path to the file where the access token is saved.
+ ///
+ protected string AccessTokenPath
+ {
+ get
+ {
+ return this.Parameters.GetValue(nameof(this.AccessTokenPath));
+ }
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ 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);
}
}
@@ -83,8 +115,8 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel
try
{
- IKeyVaultManager keyVault = await this.GetKeyVaultManager(cancellationToken);
- X509Certificate2 certificate = await keyVault.GetCertificateAsync(this.CertificateName, cancellationToken);
+ IKeyVaultManager keyVault = this.GetKeyVaultManager(cancellationToken);
+ X509Certificate2 certificate = await keyVault.GetCertificateAsync(this.Platform, this.CertificateName, cancellationToken);
if (this.Platform == PlatformID.Win32NT)
{
@@ -199,7 +231,7 @@ await this.fileSystem.File.WriteAllBytesAsync(
///
///
///
- protected async Task GetKeyVaultManager(CancellationToken cancellationToken)
+ protected IKeyVaultManager GetKeyVaultManager(CancellationToken cancellationToken)
{
IKeyVaultManager keyVaultManager = this.Dependencies.GetService();
keyVaultManager.ThrowIfNull(nameof(keyVaultManager));
@@ -209,22 +241,11 @@ protected async Task GetKeyVaultManager(CancellationToken canc
{
return keyVaultManager;
}
- else if (!string.IsNullOrWhiteSpace(this.LogFileName))
+ else if (!string.IsNullOrWhiteSpace(this.AccessToken))
{
- string directory = !string.IsNullOrWhiteSpace(this.LogFolderName)
- ? this.LogFolderName
- : this.fileSystem.Directory.GetCurrentDirectory();
-
- string accessTokenPath = this.Combine(directory, this.LogFileName);
-
- string keyVaultUri = string.IsNullOrWhiteSpace(this.KeyVaultUri)
- ? ((DependencyKeyVaultStore)keyVaultManager.StoreDescription).EndpointUri.ToString()
- : this.KeyVaultUri;
-
- keyVaultUri.ThrowIfNullOrWhiteSpace(nameof(this.KeyVaultUri));
+ this.KeyVaultUri.ThrowIfNullOrWhiteSpace(nameof(this.KeyVaultUri));
- string token_ = await this.fileSystem.File.ReadAllTextAsync(accessTokenPath, cancellationToken).ConfigureAwait(false);
- AccessTokenCredential tokenCredential = new AccessTokenCredential(token_);
+ AccessTokenCredential tokenCredential = new AccessTokenCredential(this.AccessToken);
DependencyKeyVaultStore dependencyKeyVault = new DependencyKeyVaultStore(DependencyStore.KeyVault, new Uri(this.KeyVaultUri), tokenCredential);
return new KeyVaultManager(dependencyKeyVault);
From 14c5f5702f947c6732c15c3a21d3aafaa8906167 Mon Sep 17 00:00:00 2001
From: Nirjan Chapagain
Date: Thu, 15 Jan 2026 14:26:38 -0800
Subject: [PATCH 09/28] foo
---
.../EndpointUtilityTests.cs | 38 ++-
.../KeyVaultManagerTests.cs | 17 +-
.../VirtualClient.Core/IKeyVaultManager.cs | 2 -
.../Identity/EndPointSettingsExtensions.cs | 302 ------------------
.../Identity/EndpointSettings.cs | 49 ---
.../VirtualClient.Core/KeyVaultManager.cs | 4 +-
.../CertificateInstallation.cs | 9 +-
.../KeyVaultManagerTests.cs | 12 -
.../VirtualClient.Main/Program.cs | 17 +-
.../profiles/INSTALL-CERTIFICATES.json | 22 --
.../CommandLineOptionTests.cs | 39 +++
11 files changed, 98 insertions(+), 413 deletions(-)
delete mode 100644 src/VirtualClient/VirtualClient.Core/Identity/EndPointSettingsExtensions.cs
delete mode 100644 src/VirtualClient/VirtualClient.Core/Identity/EndpointSettings.cs
delete mode 100644 src/VirtualClient/VirtualClient.IntegrationTests/KeyVaultManagerTests.cs
delete mode 100644 src/VirtualClient/VirtualClient.Main/profiles/INSTALL-CERTIFICATES.json
diff --git a/src/VirtualClient/VirtualClient.Core.UnitTests/EndpointUtilityTests.cs b/src/VirtualClient/VirtualClient.Core.UnitTests/EndpointUtilityTests.cs
index 960f157e7d..34fd05b29f 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.UnitTests/KeyVaultManagerTests.cs b/src/VirtualClient/VirtualClient.Core.UnitTests/KeyVaultManagerTests.cs
index aefc00d3a4..91aaf29636 100644
--- a/src/VirtualClient/VirtualClient.Core.UnitTests/KeyVaultManagerTests.cs
+++ b/src/VirtualClient/VirtualClient.Core.UnitTests/KeyVaultManagerTests.cs
@@ -115,20 +115,13 @@ public async Task KeyVaultManagerReturnsExpectedKey()
}
[Test]
- [TestCase(true)]
- [TestCase(false)]
- public async Task KeyVaultManagerReturnsExpectedCertificate(bool retrieveWithPrivateKey)
+ [TestCase(PlatformID.Unix)]
+ [TestCase(PlatformID.Win32NT)]
+ public async Task KeyVaultManagerReturnsExpectedCertificate(PlatformID platform)
{
- var result = await this.keyVaultManager.GetCertificateAsync(PlatformID.Unix, "mycert", CancellationToken.None, "https://myvault.vault.azure.net/", retrieveWithPrivateKey);
+ var result = await this.keyVaultManager.GetCertificateAsync(platform, "mycert", CancellationToken.None, "https://myvault.vault.azure.net/");
Assert.IsNotNull(result);
- if (retrieveWithPrivateKey)
- {
- Assert.IsTrue(result.HasPrivateKey);
- }
- else
- {
- Assert.IsFalse(result.HasPrivateKey);
- }
+ Assert.IsTrue(result.HasPrivateKey);
}
[Test]
diff --git a/src/VirtualClient/VirtualClient.Core/IKeyVaultManager.cs b/src/VirtualClient/VirtualClient.Core/IKeyVaultManager.cs
index 292347405d..13d4a2b8c4 100644
--- a/src/VirtualClient/VirtualClient.Core/IKeyVaultManager.cs
+++ b/src/VirtualClient/VirtualClient.Core/IKeyVaultManager.cs
@@ -65,7 +65,6 @@ Task GetKeyAsync(
/// The name of the certificate to be retrieved
/// A token that can be used to cancel the operation.
/// The URI of the Azure Key Vault.
- /// flag to decode whether to retrieve certificate with private key
/// A policy to use for handling retries when transient errors/failures happen.
///
/// A containing the certificate.
@@ -78,7 +77,6 @@ Task GetCertificateAsync(
string certName,
CancellationToken cancellationToken,
string keyVaultUri = null,
- bool retrieveWithPrivateKey = false,
IAsyncPolicy retryPolicy = null);
}
}
diff --git a/src/VirtualClient/VirtualClient.Core/Identity/EndPointSettingsExtensions.cs b/src/VirtualClient/VirtualClient.Core/Identity/EndPointSettingsExtensions.cs
deleted file mode 100644
index 1dce8ad002..0000000000
--- a/src/VirtualClient/VirtualClient.Core/Identity/EndPointSettingsExtensions.cs
+++ /dev/null
@@ -1,302 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text.RegularExpressions;
-using VirtualClient.Contracts;
-
-namespace VirtualClient.Identity
-{
- internal static class EndPointSettingsExtensions
- {
- internal const string AllowedPackageUri = "https://packages.virtualclient.microsoft.com";
-
- ///
- /// Returns true/false whether the value is a custom Virtual Client connection string
- /// (e.g. EndpointUrl=https://any.blob.core.windows.net;ManagedIdentityId=307591a4-abb2-4559-af59-b47177d140cf).
- ///
- /// The value to evaluate.
- /// True if the value is a custom Virtual Client connection string. False if not.
- public static bool IsCustomConnectionString(string value)
- {
- bool isConnectionString = false;
- StringComparison ignoreCase = StringComparison.OrdinalIgnoreCase;
-
- if (value.Contains($"{ConnectionParameter.EndpointUrl}=", ignoreCase)
- || value.Contains($"{ConnectionParameter.EventHubNamespace}=", ignoreCase)
- || value.Contains($"{ConnectionParameter.ClientId}=", ignoreCase)
- || value.Contains($"{ConnectionParameter.TenantId}=", ignoreCase)
- || value.Contains($"{ConnectionParameter.ManagedIdentityId}=", ignoreCase)
- || value.Contains($"{ConnectionParameter.CertificateIssuer}=", ignoreCase)
- || value.Contains($"{ConnectionParameter.CertificateSubject}=", ignoreCase)
- || value.Contains($"{ConnectionParameter.CertificateThumbprint}=", ignoreCase))
- {
- isConnectionString = true;
- }
-
- return isConnectionString;
- }
-
- ///
- /// Returns true/false whether the value is a custom Virtual Client URI
- /// (e.g. https://any.service.azure.com/?cid=307591a4-abb2-4559-af59-b47177d140cf&tid=985bbc17-E3a5-4fec-b0cb-40dbb8bc5959&crti=ABC&crts=any.service.com).
- ///
- /// The URI to evaluate.
- /// True if the URI is a custom Virtual Client URI. False if not.
- public static bool IsCustomUri(Uri endpointUri)
- {
- return Regex.IsMatch(
- endpointUri.Query,
- $"{UriParameter.CertificateIssuer}=|{UriParameter.CertificateSubject}=|{UriParameter.CertificateThumbprint}=|{UriParameter.ClientId}=|{UriParameter.ManagedIdentityId}=|{UriParameter.TenantId}=",
- RegexOptions.IgnoreCase);
- }
-
- ///
- /// Returns true/false whether the value is an Event Hub namespace access policy/connection string.
- /// (e.g. Endpoint=sb://any.servicebus.windows.net/;SharedAccessKeyName=AnyAccessPolicy;SharedAccessKey=...).
- ///
- /// The value to evaluate.
- /// True if the value is an Event Hub namespace connection string. False if not.
- public static bool IsEventHubConnectionString(string value)
- {
- return Regex.IsMatch(
- value,
- $"{ConnectionParameter.Endpoint}=|{ConnectionParameter.SharedAccessKeyName}=",
- RegexOptions.IgnoreCase);
- }
-
- ///
- /// Returns true/false whether the value is a standard Storage Account connection string.
- /// (e.g. DefaultEndpointsProtocol=https;AccountName=anystorage;EndpointSuffix=core.windows.net...).
- ///
- /// The value to evaluate.
- /// True if the value is a Storage Account connection string. False if not.
- public static bool IsStorageAccountConnectionString(string value)
- {
- return Regex.IsMatch(
- value,
- $"{ConnectionParameter.DefaultEndpointsProtocol}=|{ConnectionParameter.BlobEndpoint}=",
- RegexOptions.IgnoreCase);
- }
-
- ///
- /// Returns true/false whether the value is a standard Storage Account connection string.
- /// (e.g. DefaultEndpointsProtocol=https;AccountName=anystorage;EndpointSuffix=core.windows.net...).
- ///
- /// The URI to evaluate.
- /// True if the value is a Storage Account connection string. False if not.
- public static bool IsStorageAccountSasUri(Uri endpointUri)
- {
- return Regex.IsMatch(endpointUri.Query, "sv=|se=|spr=|sig=", RegexOptions.IgnoreCase);
- }
-
- ///
- /// Parses the subject name and issuer from the provided uri. If the uri does not contain the correctly formatted certificate subject name
- /// and issuer information the method will return false, and keep the two out parameters as null.
- /// Ex. https://vegaprod01proxyapi.azurewebsites.net?crti=issuerName&crts=certSubject
- ///
- /// The uri to attempt to parse the values from.
- /// The issuer of the certificate.
- /// The subject of the certificate.
- /// True/False if the method was able to successfully parse both the subject name and the issuer of the certificate.
- public static bool TryParseCertificateReference(Uri uri, out string issuer, out string subject)
- {
- string queryString = Uri.UnescapeDataString(uri.Query).Trim('?').Replace("&", ",,,");
-
- IDictionary queryParameters = TextParsingExtensions.ParseDelimitedValues(queryString)?.ToDictionary(
- entry => entry.Key,
- entry => entry.Value?.ToString(),
- StringComparer.OrdinalIgnoreCase);
-
- return TryGetCertificateReferenceForUri(queryParameters, out issuer, out subject);
- }
-
- ///
- /// 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.
- ///
- /// endpoint to verify and format
- ///
- public static string ValidateAndFormatPackageUri(string endpoint)
- {
- string packageUri = new Uri(AllowedPackageUri).Host;
- return packageUri == endpoint ? $"https://{endpoint}" : endpoint;
- }
-
- internal static bool TryGetEndpointForConnection(IDictionary connectionParameters, out string endpoint)
- {
- bool endpointDefined = false;
- endpoint = null;
-
- if (connectionParameters?.Any() == true)
- {
- if ((connectionParameters.TryGetValue(ConnectionParameter.Endpoint, out endpoint) || connectionParameters.TryGetValue(ConnectionParameter.EndpointUrl, out endpoint))
- && !string.IsNullOrWhiteSpace(endpoint))
- {
- endpointDefined = true;
- }
- }
-
- return endpointDefined;
- }
-
- internal static bool TryGetCertificateReferenceForConnection(IDictionary uriParameters, out string certificateThumbPrint)
- {
- bool parametersDefined = false;
- certificateThumbPrint = null;
-
- if (uriParameters?.Any() == true)
- {
- if (uriParameters.TryGetValue(ConnectionParameter.CertificateThumbprint, out string thumbprint)
- && !string.IsNullOrWhiteSpace(thumbprint))
- {
- certificateThumbPrint = thumbprint;
- parametersDefined = true;
- }
- }
-
- return parametersDefined;
- }
-
- internal static bool TryGetCertificateReferenceForConnection(IDictionary connectionParameters, out string certificateIssuer, out string certificateSubject)
- {
- bool parametersDefined = false;
- certificateIssuer = null;
- certificateSubject = null;
-
- if (connectionParameters?.Any() == true)
- {
- if (connectionParameters.TryGetValue(ConnectionParameter.CertificateIssuer, out string issuer)
- && connectionParameters.TryGetValue(ConnectionParameter.CertificateSubject, out string subject)
- && !string.IsNullOrWhiteSpace(issuer)
- && !string.IsNullOrWhiteSpace(subject))
- {
- certificateIssuer = issuer;
- certificateSubject = subject;
- parametersDefined = true;
- }
- }
-
- return parametersDefined;
- }
-
- internal static bool TryGetManagedIdentityReferenceForConnection(IDictionary connectionParameters, out string managedIdentityId)
- {
- bool parametersDefined = false;
- managedIdentityId = null;
-
- if (connectionParameters?.Any() == true)
- {
- if (connectionParameters.TryGetValue(ConnectionParameter.ManagedIdentityId, out string managedIdentityClientId)
- && !string.IsNullOrWhiteSpace(managedIdentityClientId))
- {
- managedIdentityId = managedIdentityClientId;
- parametersDefined = true;
- }
- }
-
- return parametersDefined;
- }
-
- internal static bool TryGetMicrosoftEntraReferenceForConnection(IDictionary connectionParameters, out string clientId, out string tenantId)
- {
- bool parametersDefined = false;
- clientId = null;
- tenantId = null;
-
- if (connectionParameters?.Any() == true)
- {
- if (connectionParameters.TryGetValue(ConnectionParameter.ClientId, out string microsoftEntraClientId)
- && connectionParameters.TryGetValue(ConnectionParameter.TenantId, out string microsoftEntraTenantId)
- && !string.IsNullOrWhiteSpace(microsoftEntraClientId)
- && !string.IsNullOrWhiteSpace(microsoftEntraTenantId))
- {
- clientId = microsoftEntraClientId;
- tenantId = microsoftEntraTenantId;
- parametersDefined = true;
- }
- }
-
- return parametersDefined;
- }
-
- internal static bool TryGetCertificateReferenceForUri(IDictionary uriParameters, out string certificateThumbPrint)
- {
- bool parametersDefined = false;
- certificateThumbPrint = null;
-
- if (uriParameters?.Any() == true)
- {
- if (uriParameters.TryGetValue(UriParameter.CertificateThumbprint, out string thumbprint)
- && !string.IsNullOrWhiteSpace(thumbprint))
- {
- certificateThumbPrint = thumbprint;
- parametersDefined = true;
- }
- }
-
- return parametersDefined;
- }
-
- internal static bool TryGetCertificateReferenceForUri(IDictionary uriParameters, out string certificateIssuer, out string certificateSubject)
- {
- bool parametersDefined = false;
- certificateIssuer = null;
- certificateSubject = null;
-
- if (uriParameters?.Any() == true)
- {
- if (uriParameters.TryGetValue(UriParameter.CertificateIssuer, out string issuer)
- && uriParameters.TryGetValue(UriParameter.CertificateSubject, out string subject)
- && !string.IsNullOrWhiteSpace(issuer)
- && !string.IsNullOrWhiteSpace(subject))
- {
- certificateIssuer = issuer;
- certificateSubject = subject;
- parametersDefined = true;
- }
- }
-
- return parametersDefined;
- }
-
- internal static bool TryGetManagedIdentityReferenceForUri(IDictionary uriParameters, out string managedIdentityId)
- {
- bool parametersDefined = false;
- managedIdentityId = null;
-
- if (uriParameters?.Any() == true)
- {
- if (uriParameters.TryGetValue(UriParameter.ManagedIdentityId, out string managedIdentityClientId)
- && !string.IsNullOrWhiteSpace(managedIdentityClientId))
- {
- managedIdentityId = managedIdentityClientId;
- parametersDefined = true;
- }
- }
-
- return parametersDefined;
- }
-
- internal static bool TryGetMicrosoftEntraReferenceForUri(IDictionary uriParameters, out string clientId, out string tenantId)
- {
- bool parametersDefined = false;
- clientId = null;
- tenantId = null;
-
- if (uriParameters?.Any() == true)
- {
- if (uriParameters.TryGetValue(UriParameter.ClientId, out string microsoftEntraClientId)
- && uriParameters.TryGetValue(UriParameter.TenantId, out string microsoftEntraTenantId)
- && !string.IsNullOrWhiteSpace(microsoftEntraClientId)
- && !string.IsNullOrWhiteSpace(microsoftEntraTenantId))
- {
- clientId = microsoftEntraClientId;
- tenantId = microsoftEntraTenantId;
- parametersDefined = true;
- }
- }
-
- return parametersDefined;
- }
- }
-}
diff --git a/src/VirtualClient/VirtualClient.Core/Identity/EndpointSettings.cs b/src/VirtualClient/VirtualClient.Core/Identity/EndpointSettings.cs
deleted file mode 100644
index de4a555ab0..0000000000
--- a/src/VirtualClient/VirtualClient.Core/Identity/EndpointSettings.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-namespace VirtualClient.Identity
-{
- using System;
- using VirtualClient.Common.Extensions;
-
- internal class EndpointSettings
- {
- private static readonly char[] TrimChars = { '\'', '"', ' ' };
-
- public EndpointSettings(string endpoint)
- {
- endpoint.ThrowIfNullOrWhiteSpace(nameof(endpoint));
-
- endpoint = EndPointSettingsExtensions.ValidateAndFormatPackageUri(endpoint);
- string argumentValue = endpoint.Trim(EndpointSettings.TrimChars);
-
- // this.IsBlobStoreConnectionString = EndPointSettingsExtensions.IsBlobStoreConnectionString(argumentValue);
-
- // this.EndPoint = new Uri(endpoint);
- this.IsCustomConnectionString = EndPointSettingsExtensions.IsCustomConnectionString(endpoint);
-
- this.IsEventHubConnectionString = EndPointSettingsExtensions.IsEventHubConnectionString(endpoint);
- // this.IsKeyVaultConnectionString = EndPointSettingsExtensions.IsKeyVaultConnectionString(endpoint);
-
- }
-
- public Uri EndPoint { get; set; }
-
- public bool IsCustomConnectionString { get; set; }
-
- public bool IsBlobStoreConnectionString { get; set; }
-
- public bool IsEventHubConnectionString { get; set; }
-
- public bool IsKeyVaultConnectionString { get; set; }
-
- public string TenantId { get; set; }
-
- public string ClientId { get; set; }
-
- public string ManagedIdentityId { get; set; }
-
- public string CertificateThumbprint { get; set; }
-
- public string CertificateSubjectName { get; set; }
-
- public string CertificateIssuerName { get; set; }
- }
-}
diff --git a/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs b/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs
index 8676792d8e..4f26375608 100644
--- a/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs
+++ b/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs
@@ -189,11 +189,10 @@ public async Task GetKeyAsync(
///
/// Retrieves a certificate from the Azure Key Vault.
///
- ///
+ /// The operating system platform.
/// The name of the certificate to be retrieved
/// A token that can be used to cancel the operation.
/// The URI of the Azure Key Vault.
- /// flag to decode whether to retrieve certificate with private key
/// A policy to use for handling retries when transient errors/failures happen.
///
/// A containing the certificate
@@ -206,7 +205,6 @@ public async Task GetCertificateAsync(
string certName,
CancellationToken cancellationToken,
string keyVaultUri = null,
- bool retrieveWithPrivateKey = false,
IAsyncPolicy retryPolicy = null)
{
this.StoreDescription.ThrowIfNull(nameof(this.StoreDescription));
diff --git a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
index 453eb5fa22..e9dd3e2c46 100644
--- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
+++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
@@ -115,7 +115,7 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel
try
{
- IKeyVaultManager keyVault = this.GetKeyVaultManager(cancellationToken);
+ IKeyVaultManager keyVault = this.GetKeyVaultManager();
X509Certificate2 certificate = await keyVault.GetCertificateAsync(this.Platform, this.CertificateName, cancellationToken);
if (this.Platform == PlatformID.Win32NT)
@@ -227,16 +227,13 @@ await this.fileSystem.File.WriteAllBytesAsync(
}
///
- ///
+ /// Gets the Key Vault manager to use to retrieve certificates from Key Vault.
///
- ///
- ///
- protected IKeyVaultManager GetKeyVaultManager(CancellationToken cancellationToken)
+ protected IKeyVaultManager GetKeyVaultManager()
{
IKeyVaultManager keyVaultManager = this.Dependencies.GetService();
keyVaultManager.ThrowIfNull(nameof(keyVaultManager));
- // need better if condition.
if (keyVaultManager.StoreDescription != null)
{
return keyVaultManager;
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/Program.cs b/src/VirtualClient/VirtualClient.Main/Program.cs
index 1f458fe89e..43c452b112 100644
--- a/src/VirtualClient/VirtualClient.Main/Program.cs
+++ b/src/VirtualClient/VirtualClient.Main/Program.cs
@@ -419,10 +419,23 @@ private static Command CreateGetTokenSubCommand(DefaultSettings settings)
{
// OPTIONAL
// -------------------------------------------------------------------
- OptionFactory.CreateParametersOption(required: false),
+ // --clean
+ OptionFactory.CreateCleanOption(required: false),
+
+ // --client-id
+ OptionFactory.CreateClientIdOption(required: false, Guid.NewGuid().ToString()),
+
+ // --experiment-id
+ OptionFactory.CreateExperimentIdOption(required: false, Guid.NewGuid().ToString()),
// --key-vault
- OptionFactory.CreateKeyVaultOption(required: false)
+ OptionFactory.CreateKeyVaultOption(required: false),
+
+ // --parameters
+ OptionFactory.CreateParametersOption(required: false),
+
+ // --verbose
+ OptionFactory.CreateVerboseFlag(required: false, false)
};
return getAccessTokenCommand;
diff --git a/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-CERTIFICATES.json b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-CERTIFICATES.json
deleted file mode 100644
index e3f4fc4424..0000000000
--- a/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-CERTIFICATES.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "Description": "Installs certificate from a Azure Key Vault.",
- "Parameters": {
- "AccessToken": "{access token}",
- "KeyVaultUri": "https://yourkeyvault.vault.azure.net/",
- "CertificateName": "cert-01-name",
- "CertificatePassword": "",
- "TenantId": ""
- },
- "Dependencies": [
- {
- "Type": "CertificateInstallation",
- "Parameters": {
- "Scenario": "InstallCertificate",
- "AccessToken": "$.Parameters.AccessToken",
- "TenantId": "$.Parameters.TenantId",
- "KeyVaultUri": "$.Parameters.KeyVaultUri",
- "CertificateName": "$.Parameters.CertificateName"
- }
- }
- ]
-}
\ No newline at end of file
diff --git a/src/VirtualClient/VirtualClient.UnitTests/CommandLineOptionTests.cs b/src/VirtualClient/VirtualClient.UnitTests/CommandLineOptionTests.cs
index 8135448868..0cc22e5707 100644
--- a/src/VirtualClient/VirtualClient.UnitTests/CommandLineOptionTests.cs
+++ b/src/VirtualClient/VirtualClient.UnitTests/CommandLineOptionTests.cs
@@ -608,6 +608,45 @@ 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("--kv", "https://anyvault.vault.azure.net/?cid=1...&tid=2")]
+ [TestCase("--key-vault", "testingKV")]
+ [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"
+ };
+
+ 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; }
From f56c48599cd7ebc334273d56b941a42e68dd1c3c Mon Sep 17 00:00:00 2001
From: Nirjan Chapagain
Date: Sat, 31 Jan 2026 11:04:38 -0800
Subject: [PATCH 10/28] minor fix
---
.../VirtualClient.Dependencies/KeyVaultAccessToken.cs | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
index 166026fafe..a89449208e 100644
--- a/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
+++ b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs
@@ -41,7 +41,7 @@ public KeyVaultAccessToken(IServiceCollection dependencies, IDictionary
- protected Uri KeyVaultUri { get; set; }
+ protected string KeyVaultUri { get; set; }
///
/// Gets the Azure tenant ID used to acquire an access token.
@@ -84,13 +84,13 @@ protected override async Task InitializeAsync(EventContext telemetryContext, Can
///
protected override async Task ExecuteAsync(EventContext telemetryContext, CancellationToken cancellationToken)
{
- this.KeyVaultUri = new Uri(this.Parameters.GetValue(nameof(this.KeyVaultUri)));
- this.KeyVaultUri.ThrowIfNull(nameof(this.KeyVaultUri));
+ 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(this.KeyVaultUri, out string tenant);
+ EndpointUtility.TryParseMicrosoftEntraTenantIdReference(new Uri(this.KeyVaultUri), out string tenant);
this.TenantId = tenant;
}
@@ -207,7 +207,7 @@ protected virtual TokenRequestContext GetTokenRequestContext()
{
string[] installerTenantResourceScopes = new string[]
{
- new Uri(baseUri: this.KeyVaultUri, relativeUri: ".default").ToString(),
+ new Uri(new Uri(this.KeyVaultUri), ".default").ToString(),
// Example of a specific scope:
// "api://56e7ee83-1cf6-4048-a664-c2a08955f825/user_impersonation"
};
From c73e3cbc0d63b63a13d54cd316535b9db23ece5c Mon Sep 17 00:00:00 2001
From: Nirjan Chapagain
Date: Wed, 4 Feb 2026 23:22:07 -0800
Subject: [PATCH 11/28] added get-token and install-cert as two seperate
commands.
---
.../VirtualClient.Main/CommandBase.cs | 8 +-
.../GetAccessTokenCommand.cs | 7 ++
.../VirtualClient.Main/InstallCertCommand.cs | 82 ++++++++++++
.../VirtualClient.Main/OptionFactory.cs | 81 +++++++++++-
.../VirtualClient.Main/Program.cs | 117 ++++++++++++++++--
.../profiles/INSTALL-CERT.json | 21 ++++
6 files changed, 305 insertions(+), 11 deletions(-)
create mode 100644 src/VirtualClient/VirtualClient.Main/InstallCertCommand.cs
create mode 100644 src/VirtualClient/VirtualClient.Main/profiles/INSTALL-CERT.json
diff --git a/src/VirtualClient/VirtualClient.Main/CommandBase.cs b/src/VirtualClient/VirtualClient.Main/CommandBase.cs
index abd6666c1b..e1ea9f595d 100644
--- a/src/VirtualClient/VirtualClient.Main/CommandBase.cs
+++ b/src/VirtualClient/VirtualClient.Main/CommandBase.cs
@@ -583,7 +583,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);
@@ -927,5 +927,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
index e984d0e7bd..778a40f7aa 100644
--- a/src/VirtualClient/VirtualClient.Main/GetAccessTokenCommand.cs
+++ b/src/VirtualClient/VirtualClient.Main/GetAccessTokenCommand.cs
@@ -15,6 +15,11 @@ namespace VirtualClient
///
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.
///
@@ -34,6 +39,8 @@ public override Task ExecuteAsync(string[] args, CancellationTokenSource ca
this.Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase);
}
+ this.Parameters["KeyVaultUri"] = this.KeyVault;
+
return base.ExecuteAsync(args, cancellationTokenSource);
}
}
diff --git a/src/VirtualClient/VirtualClient.Main/InstallCertCommand.cs b/src/VirtualClient/VirtualClient.Main/InstallCertCommand.cs
new file mode 100644
index 0000000000..5619976feb
--- /dev/null
+++ b/src/VirtualClient/VirtualClient.Main/InstallCertCommand.cs
@@ -0,0 +1,82 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace VirtualClient
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Extensions.DependencyInjection;
+ using Microsoft.Extensions.Logging;
+ using VirtualClient.Common.Extensions;
+ using VirtualClient.Common.Telemetry;
+ using VirtualClient.Contracts;
+
+ ///
+ /// Command executes certificate installation operations from Azure Key Vault.
+ ///
+ internal class InstallCertCommand : 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.
+ ///
+ public string CertificateName { get; set; }
+
+ ///
+ /// Optional access token for Key Vault authentication.
+ /// When not provided, uses default Azure credential authentication (Azure CLI, Managed Identity, etc.).
+ ///
+ public string AccessToken { get; set; }
+
+ ///
+ /// Executes the certificate installation command.
+ ///
+ public override Task ExecuteAsync(string[] args, CancellationTokenSource cancellationTokenSource)
+ {
+ // Validate required parameters
+ this.ValidateParameters();
+
+ this.Timeout = ProfileTiming.OneIteration();
+ this.Profiles = new List
+ {
+ new DependencyProfileReference("INSTALL-CERT.json")
+ };
+
+ if (this.Parameters == null)
+ {
+ this.Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ 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;
+ }
+
+ return base.ExecuteAsync(args, cancellationTokenSource);
+ }
+
+ private void ValidateParameters()
+ {
+ if (string.IsNullOrWhiteSpace(this.KeyVault))
+ {
+ throw new ArgumentException("The Key Vault URI must be provided (--key-vault).");
+ }
+
+ if (string.IsNullOrWhiteSpace(this.CertificateName))
+ {
+ throw new ArgumentException("The certificate name must be provided (--certname).");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs
index 7ddc9999ef..360aa67ff1 100644
--- a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs
+++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs
@@ -595,6 +595,48 @@ public static Option CreateKeyVaultOption(bool required = false, object defaultV
OptionFactory.SetOptionRequirements(option, required, defaultValue);
+ return option;
+ }
+
+ ///
+ /// Command line option defines an access token for the Virtual Client 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" })
+ {
+ Name = "AccessToken",
+ Description = "An access token for the Virtual Client to authenticate with Key Vault.",
+ ArgumentHelpName = "",
+ AllowMultipleArgumentsPerToken = false
+ };
+
+ OptionFactory.SetOptionRequirements(option, required, defaultValue);
+
+ return option;
+ }
+
+ ///
+ /// Command line option defines the name of the certificate to install.
+ ///
+ /// Sets this option as required.
+ /// Sets the default value when none is provided.
+ public static Option CreateCertNameOption(bool required = false, object defaultValue = null)
+ {
+ Option option = new Option(
+ new string[] {"--cert-name" })
+ {
+ Name = "CertificateName",
+ Description = "The name of the certificate to install.",
+ ArgumentHelpName = "name",
+ AllowMultipleArgumentsPerToken = false
+ };
+
+ OptionFactory.SetOptionRequirements(option, required, defaultValue);
+
return option;
}
@@ -1390,6 +1432,44 @@ public static Option CreateVersionOption(bool required = false)
return option;
}
+ ///
+ /// Command line option defines the authentication token for Key Vault access.
+ ///
+ /// Sets this option as required.
+ /// Sets the default value when none is provided.
+ public static Option CreateAccessTokenOption(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" })
+ {
+ 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;
+ }
+
private static string GetValue(ArgumentResult result)
{
return result.Tokens?.FirstOrDefault()?.Value?.Trim(OptionFactory.argumentTrimChars);
@@ -1611,7 +1691,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 43c452b112..bfba120910 100644
--- a/src/VirtualClient/VirtualClient.Main/Program.cs
+++ b/src/VirtualClient/VirtualClient.Main/Program.cs
@@ -325,7 +325,7 @@ internal static CommandLineBuilder SetupCommandLine(string[] args, CancellationT
apiSubcommand.Handler = CommandHandler.Create(cmd => cmd.ExecuteAsync(args, cancellationTokenSource));
rootCommand.Add(apiSubcommand);
- Command getAccessTokenSubcommand = Program.CreateGetTokenSubCommand(settings);
+ Command getAccessTokenSubcommand = Program.CreateGetTokenSubcommand(settings);
getAccessTokenSubcommand.TreatUnmatchedTokensAsErrors = true;
getAccessTokenSubcommand.Handler = CommandHandler.Create(cmd => cmd.ExecuteAsync(args, cancellationTokenSource));
rootCommand.Add(getAccessTokenSubcommand);
@@ -360,6 +360,11 @@ internal static CommandLineBuilder SetupCommandLine(string[] args, CancellationT
uploadTelemetrySubcommand.Handler = CommandHandler.Create(cmd => cmd.ExecuteAsync(args, cancellationTokenSource));
rootCommand.Add(uploadTelemetrySubcommand);
+ Command installCertSubcommand = Program.CreateInstallCertSubcommand(settings);
+ installCertSubcommand.TreatUnmatchedTokensAsErrors = true;
+ installCertSubcommand.Handler = CommandHandler.Create(cmd => cmd.ExecuteAsync(args, cancellationTokenSource));
+ rootCommand.Add(installCertSubcommand);
+
return new CommandLineBuilder(rootCommand).WithDefaults();
}
@@ -411,12 +416,17 @@ private static Command CreateApiSubcommand(DefaultSettings settings)
return apiCommand;
}
- private static Command CreateGetTokenSubCommand(DefaultSettings settings)
+ 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
@@ -428,16 +438,13 @@ private static Command CreateGetTokenSubCommand(DefaultSettings settings)
// --experiment-id
OptionFactory.CreateExperimentIdOption(required: false, Guid.NewGuid().ToString()),
- // --key-vault
- OptionFactory.CreateKeyVaultOption(required: false),
-
// --parameters
OptionFactory.CreateParametersOption(required: false),
// --verbose
OptionFactory.CreateVerboseFlag(required: false, false)
};
-
+
return getAccessTokenCommand;
}
@@ -478,9 +485,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),
@@ -815,6 +819,101 @@ private static Command CreateUploadTelemetrySubcommand(DefaultSettings settings)
return uploadTelemetryCommand;
}
+ private static Command CreateInstallCertSubcommand(DefaultSettings settings)
+ {
+ Command installCertCommand = new Command(
+ "install-cert",
+ "Installs a certificate from a package or directly from Azure Key Vault to the local machine store.")
+ {
+ // REQUIRED
+ // -------------------------------------------------------------------
+ // --cert-name
+ OptionFactory.CreateCertNameOption(required: true),
+
+ // --key-vault
+ OptionFactory.CreateKeyVaultOption(required: true),
+
+ // OPTIONAL
+ // -------------------------------------------------------------------
+ // --token
+ OptionFactory.CreateTokenOption(required: false),
+
+ // --clean
+ OptionFactory.CreateCleanOption(required: false),
+
+ // --client-id
+ OptionFactory.CreateClientIdOption(required: false, Environment.MachineName),
+
+ // --content-store
+ OptionFactory.CreateContentStoreOption(required: false),
+
+ // --content-path
+ OptionFactory.CreateContentPathTemplateOption(required: false),
+
+ // --event-hub
+ OptionFactory.CreateEventHubStoreOption(required: false),
+
+ // --exit-wait
+ OptionFactory.CreateExitWaitOption(required: false, TimeSpan.FromMinutes(30)),
+
+ // --experiment-id
+ OptionFactory.CreateExperimentIdOption(required: false, Guid.NewGuid().ToString()),
+
+ // --iterations (for integration only. not used/always = 1)
+ OptionFactory.CreateIterationsOption(required: false),
+
+ // --layout-path (for integration only. not used.)
+ OptionFactory.CreateLayoutPathOption(required: false),
+
+ // --metadata
+ OptionFactory.CreateMetadataOption(required: false),
+
+ // --name
+ OptionFactory.CreateNameOption(required: false),
+
+ // --log-dir
+ OptionFactory.CreateLogDirectoryOption(required: false, settings.LogDirectory),
+
+ // --logger
+ OptionFactory.CreateLoggerOption(required: false, settings.Loggers),
+
+ // --log-level
+ OptionFactory.CreateLogLevelOption(required: false, LogLevel.Information),
+
+ // --log-retention
+ OptionFactory.CreateLogRetentionOption(required: false),
+
+ // --log-to-file
+ OptionFactory.CreateLogToFileFlag(required: false, settings.LogToFile),
+
+ // --package-dir
+ OptionFactory.CreatePackageDirectoryOption(required: false, settings.PackageDirectory),
+
+ // --parameters
+ OptionFactory.CreateParametersOption(required: false),
+
+ // --package-store
+ OptionFactory.CreatePackageStoreOption(required: false),
+
+ // --proxy-api
+ OptionFactory.CreateProxyApiOption(required: false),
+
+ // --system
+ OptionFactory.CreateSystemOption(required: false),
+
+ // --state-dir
+ OptionFactory.CreateStateDirectoryOption(required: false, settings.StateDirectory),
+
+ // --temp-dir
+ OptionFactory.CreateTempDirectoryOption(required: false, settings.TempDirectory),
+
+ // --verbose
+ OptionFactory.CreateVerboseFlag(required: false, false)
+ };
+
+ return installCertCommand;
+ }
+
private static void InitializeStartupLogging(string[] args)
{
List loggerProviders = new List();
diff --git a/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-CERT.json b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-CERT.json
new file mode 100644
index 0000000000..1208812190
--- /dev/null
+++ b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-CERT.json
@@ -0,0 +1,21 @@
+{
+ "Description": "Installs dependencies from a package store.",
+ "Parameters": {
+ "KeyVaultUri": "Undefined",
+ "TenantId": "Undefined",
+ "CertificateName": "Undefined",
+ "AccessTokenPath": "AccessToken.txt"
+ },
+ "Dependencies": [
+ {
+ "Type": "CertificateInstallation",
+ "Parameters": {
+ "Scenario": "InstallCertificate",
+ "TenantId": "$.Parameters.TenantId",
+ "KeyVaultUri": "$.Parameters.KeyVaultUri",
+ "CertificateName": "$.Parameters.CertificateName",
+ "AccessTokenPath": "$.Parameters.AccessTokenPath"
+ }
+ }
+ ]
+}
\ No newline at end of file
From 98b6a456b3158c212a2de6814d78bebe5e6106ec Mon Sep 17 00:00:00 2001
From: Nirjan Chapagain
Date: Wed, 11 Feb 2026 21:38:59 -0800
Subject: [PATCH 12/28] Adding UT
---
.../CertificateInstallationTests.cs | 568 ++++++++++++++++++
.../KeyVaultAccessTokenTests.cs | 7 +-
.../CertificateInstallation.cs | 33 +-
.../VirtualClient.Main/Program.cs | 1 +
.../MockFixture.cs | 10 +
.../CommandLineOptionTests.cs | 5 +-
6 files changed, 596 insertions(+), 28 deletions(-)
create mode 100644 src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs
diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs
new file mode 100644
index 0000000000..34b95ab7ca
--- /dev/null
+++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs
@@ -0,0 +1,568 @@
+// 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 async Task GetKeyVaultManager_DefaultsToPredefinedKVManager_ThenCreatesNewOneWithToken()
+ {
+ // todo: nirjan to fill
+ }
+
+ [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(
+ PlatformID.Win32NT,
+ "testCert",
+ 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(
+ PlatformID.Unix,
+ "testCert",
+ 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
index 0e4a1ded68..551b341873 100644
--- a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/KeyVaultAccessTokenTests.cs
+++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/KeyVaultAccessTokenTests.cs
@@ -164,7 +164,7 @@ 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);
@@ -288,6 +288,11 @@ public Task InitializeAsyncInternal(EventContext context, CancellationToken toke
public TokenRequestContext GetTokenRequestContextInternal()
{
+ if (this.Parameters.ContainsKey(nameof(this.KeyVaultUri)))
+ {
+ this.KeyVaultUri = this.Parameters[nameof(this.KeyVaultUri)].ToString();
+ }
+
return this.GetTokenRequestContext();
}
diff --git a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
index e9dd3e2c46..7297d837db 100644
--- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
+++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
@@ -37,22 +37,11 @@ public CertificateInstallation(IServiceCollection dependencies, IDictionary
- /// Gets the Azure tenant ID used to acquire an access token.
- ///
- protected string TenantId
- {
- get
- {
- return this.Parameters.GetValue(nameof(this.TenantId));
- }
- }
-
///
/// Gets the Azure Key Vault URI for which the access token will be requested.
/// Example: https://anyvault.vault.azure.net/
///
- protected string KeyVaultUri
+ public string KeyVaultUri
{
get
{
@@ -63,7 +52,7 @@ protected string KeyVaultUri
///
/// The name of the certificate to be retrieved
///
- protected string CertificateName
+ public string CertificateName
{
get
{
@@ -71,15 +60,10 @@ protected string CertificateName
}
}
- ///
- /// Gets the access token used to authenticate with Azure services.
- ///
- protected string AccessToken { get; set; }
-
///
/// Gets the path to the file where the access token is saved.
///
- protected string AccessTokenPath
+ public string AccessTokenPath
{
get
{
@@ -87,6 +71,11 @@ protected string AccessTokenPath
}
}
+ ///
+ /// Gets the access token used to authenticate with Azure services.
+ ///
+ public string AccessToken { get; set; }
+
///
///
///
@@ -208,10 +197,6 @@ await this.fileSystem.File.WriteAllBytesAsync(
// Permissions 777 (-rwxrwxrwx)
// https://linuxhandbook.com/linux-file-permissions/
- //
- // User = read, write, execute
- // Group = read, write, execute
- // Other = read, write, execute+
using (IProcessProxy process = this.processManager.CreateProcess("chmod", $"-R 777 {certificateDirectory}"))
{
await process.StartAndWaitAsync(cancellationToken);
@@ -240,7 +225,7 @@ protected IKeyVaultManager GetKeyVaultManager()
}
else if (!string.IsNullOrWhiteSpace(this.AccessToken))
{
- this.KeyVaultUri.ThrowIfNullOrWhiteSpace(nameof(this.KeyVaultUri));
+ 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);
diff --git a/src/VirtualClient/VirtualClient.Main/Program.cs b/src/VirtualClient/VirtualClient.Main/Program.cs
index bfba120910..83418581e8 100644
--- a/src/VirtualClient/VirtualClient.Main/Program.cs
+++ b/src/VirtualClient/VirtualClient.Main/Program.cs
@@ -819,6 +819,7 @@ private static Command CreateUploadTelemetrySubcommand(DefaultSettings settings)
return uploadTelemetryCommand;
}
+ /// Combine this with the bootstrap command
private static Command CreateInstallCertSubcommand(DefaultSettings settings)
{
Command installCertCommand = new Command(
diff --git a/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs b/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs
index 1bcc148ad1..107214fa49 100644
--- a/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs
+++ b/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs
@@ -248,6 +248,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.
///
@@ -450,6 +455,7 @@ public virtual MockFixture Setup(PlatformID platform, Architecture architecture
return mockFile.Object;
});
+ this.KeyVaultManager = new Mock(mockBehavior);
this.DiskManager = new Mock(mockBehavior);
this.Logger = new InMemoryLogger();
this.FirewallManager = new Mock(mockBehavior);
@@ -577,6 +583,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());
@@ -595,6 +604,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/CommandLineOptionTests.cs b/src/VirtualClient/VirtualClient.UnitTests/CommandLineOptionTests.cs
index 0cc22e5707..edffbafacc 100644
--- a/src/VirtualClient/VirtualClient.UnitTests/CommandLineOptionTests.cs
+++ b/src/VirtualClient/VirtualClient.UnitTests/CommandLineOptionTests.cs
@@ -618,8 +618,6 @@ public void VirtualClientCommandLineSupportsResponseFiles()
[TestCase("--experimentId", "0B692DEB-411E-4AC1-80D5-AF539AE1D6B2")]
[TestCase("--experiment-id", "0B692DEB-411E-4AC1-80D5-AF539AE1D6B2")]
[TestCase("--e", "0B692DEB-411E-4AC1-80D5-AF539AE1D6B2")]
- [TestCase("--kv", "https://anyvault.vault.azure.net/?cid=1...&tid=2")]
- [TestCase("--key-vault", "testingKV")]
[TestCase("--parameters", "helloWorld=123,,,TenantId=789203498")]
[TestCase("--pm", "testing")]
[TestCase("--verbose", null)]
@@ -629,7 +627,8 @@ public void VirtualClientGetTokenCommandSupportsOnlyExpectedOptions(string optio
{
List arguments = new List()
{
- "get-token"
+ "get-token",
+ "--kv", "https://anyvault.vault.azure.net/?cid=1...&tid=2"
};
arguments.Add(option);
From e31317b981ea3d91a61196a8f992412393ec6eb8 Mon Sep 17 00:00:00 2001
From: Nirjan Chapagain
Date: Wed, 11 Feb 2026 21:59:15 -0800
Subject: [PATCH 13/28] FixUT1
---
.../VirtualClient.Core.UnitTests/EndpointUtilityTests.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/VirtualClient/VirtualClient.Core.UnitTests/EndpointUtilityTests.cs b/src/VirtualClient/VirtualClient.Core.UnitTests/EndpointUtilityTests.cs
index 34fd05b29f..323ec33648 100644
--- a/src/VirtualClient/VirtualClient.Core.UnitTests/EndpointUtilityTests.cs
+++ b/src/VirtualClient/VirtualClient.Core.UnitTests/EndpointUtilityTests.cs
@@ -860,7 +860,7 @@ public void CreateKeyVaultStoreReference_ConnectionString_ThrowsOnInvalid()
[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")]
+ [TestCase("https://my-keyvault.vault.azure.net/?;tid=654321")]
public void TryParseMicrosoftEntraTenantIdReference_Uri_WorksAsExpected(string input)
{
// Arrange
From 3390b14e6e7c2208df633b8fce4555e916553785 Mon Sep 17 00:00:00 2001
From: Nirjan Chapagain
Date: Thu, 12 Feb 2026 00:05:25 -0800
Subject: [PATCH 14/28] minor changes
---
.../KeyVaultManagerTests.cs | 17 ++++--
.../VirtualClient.Core/IKeyVaultManager.cs | 4 +-
.../VirtualClient.Core/KeyVaultManager.cs | 61 ++++++++-----------
.../CertificateInstallationTests.cs | 29 ++++-----
.../CertificateInstallation.cs | 13 +++-
5 files changed, 67 insertions(+), 57 deletions(-)
diff --git a/src/VirtualClient/VirtualClient.Core.UnitTests/KeyVaultManagerTests.cs b/src/VirtualClient/VirtualClient.Core.UnitTests/KeyVaultManagerTests.cs
index 91aaf29636..c3c6c6ac40 100644
--- a/src/VirtualClient/VirtualClient.Core.UnitTests/KeyVaultManagerTests.cs
+++ b/src/VirtualClient/VirtualClient.Core.UnitTests/KeyVaultManagerTests.cs
@@ -115,13 +115,20 @@ public async Task KeyVaultManagerReturnsExpectedKey()
}
[Test]
- [TestCase(PlatformID.Unix)]
- [TestCase(PlatformID.Win32NT)]
- public async Task KeyVaultManagerReturnsExpectedCertificate(PlatformID platform)
+ [TestCase(true)]
+ [TestCase(false)]
+ public async Task KeyVaultManagerReturnsExpectedCertificate(bool retrieveWithPrivateKey)
{
- var result = await this.keyVaultManager.GetCertificateAsync(platform, "mycert", CancellationToken.None, "https://myvault.vault.azure.net/");
+ var result = await this.keyVaultManager.GetCertificateAsync("mycert", CancellationToken.None, "https://myvault.vault.azure.net/", retrieveWithPrivateKey);
Assert.IsNotNull(result);
- Assert.IsTrue(result.HasPrivateKey);
+ if (retrieveWithPrivateKey)
+ {
+ Assert.IsTrue(result.HasPrivateKey);
+ }
+ else
+ {
+ Assert.IsFalse(result.HasPrivateKey);
+ }
}
[Test]
diff --git a/src/VirtualClient/VirtualClient.Core/IKeyVaultManager.cs b/src/VirtualClient/VirtualClient.Core/IKeyVaultManager.cs
index 13d4a2b8c4..2c256b1450 100644
--- a/src/VirtualClient/VirtualClient.Core/IKeyVaultManager.cs
+++ b/src/VirtualClient/VirtualClient.Core/IKeyVaultManager.cs
@@ -61,10 +61,10 @@ Task GetKeyAsync(
///
/// Retrieves a certificate from the Azure Key Vault.
///
- /// The operating system platform (e.g. Windows, Linux).
/// The name of the certificate to be retrieved
/// A token that can be used to cancel the operation.
/// The URI of the Azure Key Vault.
+ /// flag to decode whether to retrieve certificate with private key
/// A policy to use for handling retries when transient errors/failures happen.
///
/// A containing the certificate.
@@ -73,10 +73,10 @@ Task GetKeyAsync(
/// Thrown if the certificate is not found, access is denied, or another error occurs.
///
Task GetCertificateAsync(
- PlatformID platform,
string certName,
CancellationToken cancellationToken,
string keyVaultUri = null,
+ bool retrieveWithPrivateKey = false,
IAsyncPolicy retryPolicy = null);
}
}
diff --git a/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs b/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs
index 4f26375608..03b811a12f 100644
--- a/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs
+++ b/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs
@@ -189,10 +189,10 @@ public async Task GetKeyAsync(
///
/// Retrieves a certificate from the Azure Key Vault.
///
- /// The operating system platform.
/// The name of the certificate to be retrieved
/// A token that can be used to cancel the operation.
/// The URI of the Azure Key Vault.
+ /// flag to decode whether to retrieve certificate with private key
/// A policy to use for handling retries when transient errors/failures happen.
///
/// A containing the certificate
@@ -201,12 +201,13 @@ public async Task GetKeyAsync(
/// Thrown if the certificate is not found, access is denied, or another error occurs.
///
public async Task GetCertificateAsync(
- PlatformID platform,
string certName,
CancellationToken cancellationToken,
string keyVaultUri = null,
+ bool retrieveWithPrivateKey = false,
IAsyncPolicy retryPolicy = null)
{
+ this.ValidateKeyVaultStore();
this.StoreDescription.ThrowIfNull(nameof(this.StoreDescription));
certName.ThrowIfNullOrWhiteSpace(nameof(certName), "The certificate name cannot be null or empty.");
@@ -215,47 +216,37 @@ public async Task GetCertificateAsync(
? new Uri(keyVaultUri)
: ((DependencyKeyVaultStore)this.StoreDescription).EndpointUri;
+ CertificateClient client = this.CreateCertificateClient(vaultUri, ((DependencyKeyVaultStore)this.StoreDescription).Credentials);
+
try
{
- KeyVaultSecret keyVaultSecret = await (retryPolicy ?? KeyVaultManager.DefaultRetryPolicy).ExecuteAsync(async () =>
+ return await (retryPolicy ?? KeyVaultManager.DefaultRetryPolicy).ExecuteAsync(async () =>
{
- SecretClient secretsClient = new SecretClient(vaultUri, ((DependencyKeyVaultStore)this.StoreDescription).Credentials);
- Response response = await secretsClient.GetSecretAsync(certName, version: null, cancellationToken);
+ // Get the full certificate with private key (PFX) if requested
+ if (retrieveWithPrivateKey)
+ {
+ X509Certificate2 privateKeyCert = await client
+ .DownloadCertificateAsync(certName, cancellationToken: cancellationToken)
+ .ConfigureAwait(false);
- return response.Value;
- }).ConfigureAwait(false);
-
- byte[] privateKeyBytes = Convert.FromBase64String(keyVaultSecret.Value);
- X509Certificate2 certificate = null;
-
- var keyStorageFlags = X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet;
+ if (privateKeyCert is null || !privateKeyCert.HasPrivateKey)
+ {
+ throw new DependencyException("Failed to retrieve certificate content with private key.");
+ }
+ return privateKeyCert;
+ }
+ else
+ {
+ // If private key not needed, load cert from PublicBytes
+ KeyVaultCertificateWithPolicy cert = await client.GetCertificateAsync(certName, cancellationToken: cancellationToken);
#if NET9_0_OR_GREATER
- if (platform == PlatformID.Unix)
- {
- certificate = X509CertificateLoader.LoadPkcs12(privateKeyBytes, null, X509KeyStorageFlags.PersistKeySet);
- }
- else if (platform == PlatformID.Win32NT)
- {
- certificate = X509CertificateLoader.LoadPkcs12(privateKeyBytes, null, keyStorageFlags);
- }
+ return X509CertificateLoader.LoadCertificate(cert.Cer);
#elif NET8_0_OR_GREATER
- if (platform == PlatformID.Unix)
- {
- certificate = new X509Certificate2(privateKeyBytes, (string)null, X509KeyStorageFlags.PersistKeySet);
- }
- else if (platform == PlatformID.Win32NT)
- {
- certificate = new X509Certificate2(privateKeyBytes, (string)null, keyStorageFlags);
- }
+ return new X509Certificate2(cert.Cer);
#endif
-
- if (certificate is null || !certificate.HasPrivateKey)
- {
- throw new DependencyException("Failed to retrieve certificate content with private key.");
- }
-
- return certificate;
+ }
+ }).ConfigureAwait(false);
}
catch (RequestFailedException ex) when (ex.Status == (int)HttpStatusCode.Forbidden)
{
diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs
index 34b95ab7ca..3ea6197f6a 100644
--- a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs
+++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs
@@ -1,6 +1,3 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
namespace VirtualClient.Dependencies
{
using System;
@@ -123,12 +120,6 @@ public async Task InitializeAsync_PrefersAccessTokenParameterOverFile()
}
}
- [Test]
- public async Task GetKeyVaultManager_DefaultsToPredefinedKVManager_ThenCreatesNewOneWithToken()
- {
- // todo: nirjan to fill
- }
-
[Test]
public void ExecuteAsync_ThrowsWhenCertificateNameIsNull()
{
@@ -163,7 +154,12 @@ public async Task ExecuteAsyncInstallsCertificateOnWindows()
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()))
+ .Setup(m => m.GetCertificateAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
.ReturnsAsync(this.testCertificate);
component.OnInstallCertificateOnWindows = (cert, token) =>
@@ -178,10 +174,10 @@ public async Task ExecuteAsyncInstallsCertificateOnWindows()
Assert.IsTrue(windowsInstallCalled);
this.mockFixture.KeyVaultManager.Verify(m => m.GetCertificateAsync(
- PlatformID.Win32NT,
"testCert",
It.IsAny(),
It.IsAny(),
+ It.IsAny(),
It.IsAny()), Times.Once);
}
@@ -200,7 +196,12 @@ public async Task ExecuteAsyncInstallsCertificateOnUnix()
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()))
+ .Setup(m => m.GetCertificateAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
.ReturnsAsync(this.testCertificate);
component.OnInstallCertificateOnUnix = (cert, token) =>
@@ -215,10 +216,10 @@ public async Task ExecuteAsyncInstallsCertificateOnUnix()
Assert.IsTrue(unixInstallCalled);
this.mockFixture.KeyVaultManager.Verify(m => m.GetCertificateAsync(
- PlatformID.Unix,
"testCert",
It.IsAny(),
It.IsAny(),
+ It.IsAny(),
It.IsAny()), Times.Once);
}
@@ -234,10 +235,10 @@ public void ExecuteAsync_WrapsExceptionsInDependencyException()
this.mockFixture.KeyVaultManager
.Setup(m => m.GetCertificateAsync(
- It.IsAny(),
It.IsAny(),
It.IsAny(),
It.IsAny(),
+ It.IsAny(),
It.IsAny()))
.ThrowsAsync(new InvalidOperationException("KeyVault error"));
diff --git a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
index 7297d837db..6abea97b8f 100644
--- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
+++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
@@ -71,6 +71,17 @@ public string 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.
///
@@ -105,7 +116,7 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel
try
{
IKeyVaultManager keyVault = this.GetKeyVaultManager();
- X509Certificate2 certificate = await keyVault.GetCertificateAsync(this.Platform, this.CertificateName, cancellationToken);
+ X509Certificate2 certificate = await keyVault.GetCertificateAsync(this.CertificateName, cancellationToken, null, this.WithPrivateKey);
if (this.Platform == PlatformID.Win32NT)
{
From a64b83a481f146f635457ade54b703da6c6859d6 Mon Sep 17 00:00:00 2001
From: Nirjan Chapagain
Date: Thu, 12 Feb 2026 16:28:45 -0800
Subject: [PATCH 15/28] Including everything in bootstrap
---
.../CertificateInstallation.cs | 3 +-
.../BootstrapPackageCommand.cs | 107 +++++++++++++++---
.../VirtualClient.Main/OptionFactory.cs | 2 +-
.../profiles/BOOTSTRAP-DEPENDENCIES.json | 44 ++++---
4 files changed, 123 insertions(+), 33 deletions(-)
diff --git a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
index 6abea97b8f..010c6d4398 100644
--- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
+++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs
@@ -245,7 +245,8 @@ protected IKeyVaultManager GetKeyVaultManager()
}
else
{
- throw new InvalidOperationException($"The Key Vault manager has not been properly initialized. The '{nameof(this.LogFileName)}' parameter must be provided to read the access token from file.");
+ 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.");
}
}
}
diff --git a/src/VirtualClient/VirtualClient.Main/BootstrapPackageCommand.cs b/src/VirtualClient/VirtualClient.Main/BootstrapPackageCommand.cs
index 4f9212d03f..1eada0c5bf 100644
--- a/src/VirtualClient/VirtualClient.Main/BootstrapPackageCommand.cs
+++ b/src/VirtualClient/VirtualClient.Main/BootstrapPackageCommand.cs
@@ -3,19 +3,41 @@
namespace VirtualClient
{
+ using Microsoft.Extensions.DependencyInjection;
+ using Microsoft.Extensions.Logging;
+ using Serilog.Core;
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 +46,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 only (--package)
+ /// - Certificate installation only (--cert-name)
+ /// - Certificate then package installation (--cert-name --package)
///
- /// 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 +66,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);
+ }
+
+ private 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 " +
+ "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.");
+ }
+ }
+
+ private 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;
+ }
+ }
+
+ private 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/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs
index fda429e476..94350c53ab 100644
--- a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs
+++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs
@@ -940,7 +940,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
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
From e668337fbb775e77cf9944179d5b7bf1a5bc0e36 Mon Sep 17 00:00:00 2001
From: Nirjan Chapagain
Date: Thu, 12 Feb 2026 21:29:05 -0800
Subject: [PATCH 16/28] combined with bootstrap
---
.../BootstrapPackageCommand.cs | 10 +-
.../VirtualClient.Main/OptionFactory.cs | 58 +------
.../VirtualClient.Main/Program.cs | 151 ++++++------------
3 files changed, 60 insertions(+), 159 deletions(-)
diff --git a/src/VirtualClient/VirtualClient.Main/BootstrapPackageCommand.cs b/src/VirtualClient/VirtualClient.Main/BootstrapPackageCommand.cs
index 1eada0c5bf..4e97517a22 100644
--- a/src/VirtualClient/VirtualClient.Main/BootstrapPackageCommand.cs
+++ b/src/VirtualClient/VirtualClient.Main/BootstrapPackageCommand.cs
@@ -51,9 +51,9 @@ internal class BootstrapPackageCommand : ExecuteProfileCommand
///
/// Executes the bootstrap command.
/// Supports:
- /// - Package installation only (--package)
- /// - Certificate installation only (--cert-name)
- /// - Certificate then package installation (--cert-name --package)
+ /// - 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)
///
public override Task ExecuteAsync(string[] args, CancellationTokenSource cancellationTokenSource)
{
@@ -97,7 +97,7 @@ private void ValidateParameters()
if (string.IsNullOrWhiteSpace(this.PackageName) && string.IsNullOrWhiteSpace(this.CertificateName))
{
throw new ArgumentException(
- "At least one operation must be specified. Use --package for package installation " +
+ "At least one operation must be specified. Use --packages for package installation from remote store " +
"or --cert-name for certificate installation.");
}
@@ -105,7 +105,7 @@ private void ValidateParameters()
if (!string.IsNullOrWhiteSpace(this.CertificateName) && string.IsNullOrWhiteSpace(this.KeyVault))
{
throw new ArgumentException(
- "The Key Vault URI must be provided (--key-vault) when installing certificates.");
+ "The Key Vault URI must be provided (--key-vault) when installing certificates (--cert-name).");
}
}
diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs
index 94350c53ab..b801f55d7c 100644
--- a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs
+++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs
@@ -599,44 +599,40 @@ public static Option CreateKeyVaultOption(bool required = false, object defaultV
}
///
- /// Command line option defines an access token for the Virtual Client to authenticate requests.
+ /// 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" })
+ Option option = new Option(new string[] { "--token", "--access-token" })
{
Name = "AccessToken",
- Description = "An access token for the Virtual Client to authenticate with Key Vault.",
- ArgumentHelpName = "",
+ 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 name of the certificate to install.
+ /// 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 CreateCertNameOption(bool required = false, object defaultValue = null)
+ public static Option CreateCertificateNameOption(bool required = false, object defaultValue = null)
{
- Option option = new Option(
- new string[] {"--cert-name" })
+ Option option = new Option(new string[] { "--certname", "--certificate-name", "--cert-name" })
{
Name = "CertificateName",
- Description = "The name of the certificate to install.",
+ 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;
}
@@ -1432,44 +1428,6 @@ public static Option CreateVersionOption(bool required = false)
return option;
}
- ///
- /// Command line option defines the authentication token for Key Vault access.
- ///
- /// Sets this option as required.
- /// Sets the default value when none is provided.
- public static Option CreateAccessTokenOption(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" })
- {
- 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;
- }
-
private static string GetValue(ArgumentResult result)
{
return result.Tokens?.FirstOrDefault()?.Value?.Trim(OptionFactory.argumentTrimChars);
diff --git a/src/VirtualClient/VirtualClient.Main/Program.cs b/src/VirtualClient/VirtualClient.Main/Program.cs
index 83418581e8..36484c12be 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
@@ -360,11 +363,6 @@ internal static CommandLineBuilder SetupCommandLine(string[] args, CancellationT
uploadTelemetrySubcommand.Handler = CommandHandler.Create(cmd => cmd.ExecuteAsync(args, cancellationTokenSource));
rootCommand.Add(uploadTelemetrySubcommand);
- Command installCertSubcommand = Program.CreateInstallCertSubcommand(settings);
- installCertSubcommand.TreatUnmatchedTokensAsErrors = true;
- installCertSubcommand.Handler = CommandHandler.Create(cmd => cmd.ExecuteAsync(args, cancellationTokenSource));
- rootCommand.Add(installCertSubcommand);
-
return new CommandLineBuilder(rootCommand).WithDefaults();
}
@@ -450,17 +448,30 @@ private static Command CreateGetTokenSubcommand(DefaultSettings settings)
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),
@@ -534,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 atleast 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;
}
@@ -819,102 +858,6 @@ private static Command CreateUploadTelemetrySubcommand(DefaultSettings settings)
return uploadTelemetryCommand;
}
- /// Combine this with the bootstrap command
- private static Command CreateInstallCertSubcommand(DefaultSettings settings)
- {
- Command installCertCommand = new Command(
- "install-cert",
- "Installs a certificate from a package or directly from Azure Key Vault to the local machine store.")
- {
- // REQUIRED
- // -------------------------------------------------------------------
- // --cert-name
- OptionFactory.CreateCertNameOption(required: true),
-
- // --key-vault
- OptionFactory.CreateKeyVaultOption(required: true),
-
- // OPTIONAL
- // -------------------------------------------------------------------
- // --token
- OptionFactory.CreateTokenOption(required: false),
-
- // --clean
- OptionFactory.CreateCleanOption(required: false),
-
- // --client-id
- OptionFactory.CreateClientIdOption(required: false, Environment.MachineName),
-
- // --content-store
- OptionFactory.CreateContentStoreOption(required: false),
-
- // --content-path
- OptionFactory.CreateContentPathTemplateOption(required: false),
-
- // --event-hub
- OptionFactory.CreateEventHubStoreOption(required: false),
-
- // --exit-wait
- OptionFactory.CreateExitWaitOption(required: false, TimeSpan.FromMinutes(30)),
-
- // --experiment-id
- OptionFactory.CreateExperimentIdOption(required: false, Guid.NewGuid().ToString()),
-
- // --iterations (for integration only. not used/always = 1)
- OptionFactory.CreateIterationsOption(required: false),
-
- // --layout-path (for integration only. not used.)
- OptionFactory.CreateLayoutPathOption(required: false),
-
- // --metadata
- OptionFactory.CreateMetadataOption(required: false),
-
- // --name
- OptionFactory.CreateNameOption(required: false),
-
- // --log-dir
- OptionFactory.CreateLogDirectoryOption(required: false, settings.LogDirectory),
-
- // --logger
- OptionFactory.CreateLoggerOption(required: false, settings.Loggers),
-
- // --log-level
- OptionFactory.CreateLogLevelOption(required: false, LogLevel.Information),
-
- // --log-retention
- OptionFactory.CreateLogRetentionOption(required: false),
-
- // --log-to-file
- OptionFactory.CreateLogToFileFlag(required: false, settings.LogToFile),
-
- // --package-dir
- OptionFactory.CreatePackageDirectoryOption(required: false, settings.PackageDirectory),
-
- // --parameters
- OptionFactory.CreateParametersOption(required: false),
-
- // --package-store
- OptionFactory.CreatePackageStoreOption(required: false),
-
- // --proxy-api
- OptionFactory.CreateProxyApiOption(required: false),
-
- // --system
- OptionFactory.CreateSystemOption(required: false),
-
- // --state-dir
- OptionFactory.CreateStateDirectoryOption(required: false, settings.StateDirectory),
-
- // --temp-dir
- OptionFactory.CreateTempDirectoryOption(required: false, settings.TempDirectory),
-
- // --verbose
- OptionFactory.CreateVerboseFlag(required: false, false)
- };
-
- return installCertCommand;
- }
-
private static void InitializeStartupLogging(string[] args)
{
List loggerProviders = new List();
From c940871a9e5386fcff409f76bc6090b97f4ddf69 Mon Sep 17 00:00:00 2001
From: Nirjan Chapagain
Date: Thu, 12 Feb 2026 23:03:05 -0800
Subject: [PATCH 17/28] Adding doc and UTs.
---
.../BootstrapPackageCommand.cs | 6 +-
.../profiles/INSTALL-CERT.json | 21 --
.../BootstrapCommandValidatorTests.cs | 74 +++++
.../BootstrapPackageCommandTests.cs | 260 ++++++++++++++++++
.../website/docs/guides/0010-command-line.md | 62 +++++
website/docs/guides/0010-command-line.md | 15 +-
6 files changed, 409 insertions(+), 29 deletions(-)
delete mode 100644 src/VirtualClient/VirtualClient.Main/profiles/INSTALL-CERT.json
create mode 100644 src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandValidatorTests.cs
create mode 100644 src/VirtualClient/VirtualClient.UnitTests/BootstrapPackageCommandTests.cs
create mode 100644 src/VirtualClient/website/docs/guides/0010-command-line.md
diff --git a/src/VirtualClient/VirtualClient.Main/BootstrapPackageCommand.cs b/src/VirtualClient/VirtualClient.Main/BootstrapPackageCommand.cs
index 4e97517a22..32a8976298 100644
--- a/src/VirtualClient/VirtualClient.Main/BootstrapPackageCommand.cs
+++ b/src/VirtualClient/VirtualClient.Main/BootstrapPackageCommand.cs
@@ -86,7 +86,7 @@ public override Task ExecuteAsync(string[] args, CancellationTokenSource ca
return base.ExecuteAsync(args, cancellationTokenSource);
}
- private void ValidateParameters()
+ protected void ValidateParameters()
{
if (this.Parameters == null)
{
@@ -109,7 +109,7 @@ private void ValidateParameters()
}
}
- private void SetupCertificateInstallation()
+ protected void SetupCertificateInstallation()
{
// Set certificate-related parameters
this.Parameters["KeyVaultUri"] = this.KeyVault;
@@ -122,7 +122,7 @@ private void SetupCertificateInstallation()
}
}
- private void SetupPackageInstallation()
+ protected void SetupPackageInstallation()
{
string registerAsName = this.Name;
if (String.IsNullOrWhiteSpace(registerAsName))
diff --git a/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-CERT.json b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-CERT.json
deleted file mode 100644
index 1208812190..0000000000
--- a/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-CERT.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "Description": "Installs dependencies from a package store.",
- "Parameters": {
- "KeyVaultUri": "Undefined",
- "TenantId": "Undefined",
- "CertificateName": "Undefined",
- "AccessTokenPath": "AccessToken.txt"
- },
- "Dependencies": [
- {
- "Type": "CertificateInstallation",
- "Parameters": {
- "Scenario": "InstallCertificate",
- "TenantId": "$.Parameters.TenantId",
- "KeyVaultUri": "$.Parameters.KeyVaultUri",
- "CertificateName": "$.Parameters.CertificateName",
- "AccessTokenPath": "$.Parameters.AccessTokenPath"
- }
- }
- ]
-}
\ No newline at end of file
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/website/docs/guides/0010-command-line.md b/src/VirtualClient/website/docs/guides/0010-command-line.md
new file mode 100644
index 0000000000..c1c43d3dc9
--- /dev/null
+++ b/src/VirtualClient/website/docs/guides/0010-command-line.md
@@ -0,0 +1,62 @@
+* ### bootstrap
+ 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=\ | 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.
+
+* ### 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. |
diff --git a/website/docs/guides/0010-command-line.md b/website/docs/guides/0010-command-line.md
index c6b305d419..3c2a03e381 100644
--- a/website/docs/guides/0010-command-line.md
+++ b/website/docs/guides/0010-command-line.md
@@ -137,16 +137,21 @@ VirtualClient.exe
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 certificate on the system.
+ * Option1: 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. `--package` option is required to indicate which package to install.
+
+ * Option2: This is used for example to install certificate to the system for use in workloads that require certificate authentication to target resources (e.g. Azure Storage, Azure Event Hub, Azure Key Vault). Note that the `--keyvault` option is required when bootstrapping certificates to indicate where the certificates should be sourced from.
+
| 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). |
+ | --certificateName, --cert-name =\ | No | string/Certificate Name | Defined the name of the certificate to bootstrap/install (eg: --cert-name="crc-sdk-cert" |
+ | --key-vault, --kv=' | Yes | string/connection string/SAS | {AI to fill} |
+ | --token, --access-token=' | No | Access Token | {AI to fill} |
+* | --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}" |
From 5a776516d8872ed4a332413ddf0870453036e718 Mon Sep 17 00:00:00 2001
From: Nirjan Chapagain
Date: Thu, 12 Feb 2026 23:26:59 -0800
Subject: [PATCH 18/28] Fixing doc
---
.../website/docs/guides/0010-command-line.md | 62 -----------
website/docs/guides/0010-command-line.md | 105 ++++++++++++------
2 files changed, 74 insertions(+), 93 deletions(-)
delete mode 100644 src/VirtualClient/website/docs/guides/0010-command-line.md
diff --git a/src/VirtualClient/website/docs/guides/0010-command-line.md b/src/VirtualClient/website/docs/guides/0010-command-line.md
deleted file mode 100644
index c1c43d3dc9..0000000000
--- a/src/VirtualClient/website/docs/guides/0010-command-line.md
+++ /dev/null
@@ -1,62 +0,0 @@
-* ### bootstrap
- 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=\ | 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.
-
-* ### 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. |
diff --git a/website/docs/guides/0010-command-line.md b/website/docs/guides/0010-command-line.md
index 3c2a03e381..ab3d834f1b 100644
--- a/website/docs/guides/0010-command-line.md
+++ b/website/docs/guides/0010-command-line.md
@@ -136,47 +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 and/or certificate on the system.
- * Option1: 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. `--package` option is required to indicate which package to install.
-
- * Option2: This is used for example to install certificate to the system for use in workloads that require certificate authentication to target resources (e.g. Azure Storage, Azure Event Hub, Azure Key Vault). Note that the `--keyvault` option is required when bootstrapping certificates to indicate where the certificates should be sourced from.
+ 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. |
- | --certificateName, --cert-name =\ | No | string/Certificate Name | Defined the name of the certificate to bootstrap/install (eg: --cert-name="crc-sdk-cert" |
- | --key-vault, --kv=' | Yes | string/connection string/SAS | {AI to fill} |
- | --token, --access-token=' | No | Access Token | {AI to fill} |
-* | --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=\