From d5b7e2d6e3a1ea6a4834c271f128476fb2daf86f Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Mon, 15 Jun 2026 16:12:30 -0700 Subject: [PATCH 1/7] Adding docker window server feature. --- .gitignore | 4 +- .../DockerInstallationTests.cs | 53 +++++++++++- .../DockerInstallation.cs | 86 ++++++++++++++++--- .../profiles/INSTALL-DOCKER.json | 20 +++++ 4 files changed, 146 insertions(+), 17 deletions(-) create mode 100644 src/VirtualClient/VirtualClient.Main/profiles/INSTALL-DOCKER.json diff --git a/.gitignore b/.gitignore index 7459e648dc..cdb4737651 100644 --- a/.gitignore +++ b/.gitignore @@ -313,4 +313,6 @@ __pycache__/ npm-debug.log* yarn-debug.log* yarn-error.log* -Tools/ \ No newline at end of file +Tools/ +.context/ +context/ \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs index 88cd90c416..c9d35d2ff0 100644 --- a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs +++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs @@ -46,7 +46,7 @@ public async Task DockerInstallationRunsTheExpectedCommandOnLinux(string version List expectedCommands = new List() { $"sudo {RequiredPackagesCommand}", - $"sudo {AddOfficialGPGKeyCommand}", + $"sudo {AddOfficialGPGKeyCommand}", $"sudo {SetUpRepositoryCommand}", @$"sudo bash -c ""apt-get install docker-ce=$(apt-cache madison docker-ce | grep {dockerInstallation.Version} | awk '{{print $3}}') docker-ce-cli=$(apt-cache madison docker-ce | grep {dockerInstallation.Version} | awk '{{print $3}}') containerd.io docker-compose-plugin --yes --quiet""", @$"sudo bash -c ""apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin --yes --quiet""" @@ -68,12 +68,61 @@ public async Task DockerInstallationRunsTheExpectedCommandOnLinux(string version }; return process; }; - + await dockerInstallation.ExecuteAsync(CancellationToken.None).ConfigureAwait(false); Assert.AreEqual(4, commandExecuted); } } + [Test] + [TestCase("20.10.24")] + [TestCase(null)] + public async Task DockerInstallationRunsTheExpectedCommandsOnWindows(string version) + { + this.mockFixture = new MockFixture(); + this.mockFixture.Setup(PlatformID.Win32NT); + this.mockFixture.Parameters = new Dictionary() + { + { "Version", version } + }; + + using (TestDockerInstallation dockerInstallation = new TestDockerInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + string installDockerCommand = string.IsNullOrEmpty(version) + ? "Install-Package -Name Docker -ProviderName DockerMsftProvider -Force" + : $"Install-Package -Name Docker -ProviderName DockerMsftProvider -RequiredVersion {version} -Force"; + + List expectedCommands = new List() + { + "Install-WindowsFeature -Name Containers", + "Install-Module -Name DockerMsftProvider -Repository PSGallery -Force", + installDockerCommand, + "Start-Service Docker" + }; + + List executedCommands = new List(); + this.mockFixture.ProcessManager.OnCreateProcess = (exe, arguments, workingDir) => + { + if (exe == "powershell" && expectedCommands.Any(c => c == arguments)) + { + executedCommands.Add(arguments); + } + + IProcessProxy process = new InMemoryProcess() + { + ExitCode = 0, + OnStart = () => true, + OnHasExited = () => true + }; + return process; + }; + + await dockerInstallation.ExecuteAsync(CancellationToken.None).ConfigureAwait(false); + Assert.AreEqual(4, executedCommands.Count); + CollectionAssert.AreEqual(expectedCommands, executedCommands); + } + } + private class TestDockerInstallation : DockerInstallation { public TestDockerInstallation(IServiceCollection dependencies, IDictionary parameters) diff --git a/src/VirtualClient/VirtualClient.Dependencies/DockerInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/DockerInstallation.cs index 308cc43440..970136421e 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/DockerInstallation.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/DockerInstallation.cs @@ -15,7 +15,7 @@ namespace VirtualClient.Dependencies using VirtualClient.Contracts; /// - /// Provides functionality for installing specific version of docker on linux. + /// Provides functionality for installing Docker on linux and Windows Server. /// public class DockerInstallation : VirtualClientComponent { @@ -74,7 +74,7 @@ protected override async Task InitializeAsync(EventContext telemetryContext, Can case LinuxDistribution.Ubuntu: if (this.Version != string.Empty) { - this.installDockerCommand = + this.installDockerCommand = @$"bash -c ""apt-get install docker-ce=$(apt-cache madison docker-ce | grep {this.Version} | awk '{{print $3}}') " + @$"docker-ce-cli=$(apt-cache madison docker-ce | grep {this.Version} | awk '{{print $3}}') containerd.io docker-compose-plugin --yes --quiet"""; } @@ -96,13 +96,15 @@ protected override async Task InitializeAsync(EventContext telemetryContext, Can ErrorReason.LinuxDistributionNotSupported); } } + else if (this.Platform == PlatformID.Win32NT) + { + // Windows Server Docker installation uses PowerShell. No pre-initialization needed. + } else { - // docker installation for windows to be added. throw new WorkloadException( - $"Docker Installtion is not supported on the current platform {this.Platform} through VC." + - $"Supported Platforms include:" + - $" Unix ", + $"Docker installation is not supported on the current platform {this.Platform} through VC. " + + $"Supported Platforms include: Unix, Win32NT", ErrorReason.PlatformNotSupported); } @@ -116,16 +118,24 @@ protected override async Task InitializeAsync(EventContext telemetryContext, Can /// protected override async Task ExecuteAsync(EventContext telemetryContext, CancellationToken cancellationToken) { - LinuxDistributionInfo distroInfo = await this.systemManager.GetLinuxDistributionAsync(cancellationToken) - .ConfigureAwait(false); - - switch (distroInfo.LinuxDistribution) + if (this.Platform == PlatformID.Unix) { - case LinuxDistribution.Ubuntu: - await this.DockerInstallInUbuntuAsync(telemetryContext, cancellationToken) - .ConfigureAwait(false); + LinuxDistributionInfo distroInfo = await this.systemManager.GetLinuxDistributionAsync(cancellationToken) + .ConfigureAwait(false); - break; + switch (distroInfo.LinuxDistribution) + { + case LinuxDistribution.Ubuntu: + await this.DockerInstallInUbuntuAsync(telemetryContext, cancellationToken) + .ConfigureAwait(false); + + break; + } + } + else if (this.Platform == PlatformID.Win32NT) + { + await this.DockerInstallInWindowsAsync(telemetryContext, cancellationToken) + .ConfigureAwait(false); } } @@ -160,6 +170,54 @@ await this.ExecuteCommandAsync(this.installDockerCommand, Environment.CurrentDir .ConfigureAwait(false); } + private async Task DockerInstallInWindowsAsync(EventContext telemetryContext, CancellationToken cancellationToken) + { + string enableContainersFeature = "Install-WindowsFeature -Name Containers"; + + string installDockerProvider = "Install-Module -Name DockerMsftProvider -Repository PSGallery -Force"; + + string installDocker = !string.IsNullOrEmpty(this.Version) + ? $"Install-Package -Name Docker -ProviderName DockerMsftProvider -RequiredVersion {this.Version} -Force" + : "Install-Package -Name Docker -ProviderName DockerMsftProvider -Force"; + + string startDockerService = "Start-Service Docker"; + + await this.ExecutePowerShellAsync(enableContainersFeature, telemetryContext, cancellationToken) + .ConfigureAwait(false); + + await this.ExecutePowerShellAsync(installDockerProvider, telemetryContext, cancellationToken) + .ConfigureAwait(false); + + await this.ExecutePowerShellAsync(installDocker, telemetryContext, cancellationToken) + .ConfigureAwait(false); + + await this.ExecutePowerShellAsync(startDockerService, telemetryContext, cancellationToken) + .ConfigureAwait(false); + } + + private Task ExecutePowerShellAsync(string command, EventContext telemetryContext, CancellationToken cancellationToken) + { + return this.RetryPolicy.ExecuteAsync(async () => + { + using (IProcessProxy process = this.systemManager.ProcessManager.CreateElevatedProcess(this.Platform, "powershell", command)) + { + this.CleanupTasks.Add(() => process.SafeKill(this.Logger)); + this.LogProcessTrace(process); + + await process.StartAndWaitAsync(cancellationToken) + .ConfigureAwait(false); + + if (!cancellationToken.IsCancellationRequested) + { + await this.LogProcessDetailsAsync(process, telemetryContext) + .ConfigureAwait(false); + + process.ThrowIfErrored(errorReason: ErrorReason.DependencyInstallationFailed); + } + } + }); + } + private Task ExecuteCommandAsync(string commandLine, string workingDirectory, EventContext telemetryContext, CancellationToken cancellationToken) { return this.RetryPolicy.ExecuteAsync(async () => diff --git a/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-DOCKER.json b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-DOCKER.json new file mode 100644 index 0000000000..25aae06670 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-DOCKER.json @@ -0,0 +1,20 @@ +{ + "Description": "Installs Docker Engine on the system for container-based workloads.", + "Metadata": { + "RecommendedMinimumExecutionTime": "00:05:00", + "SupportedPlatforms": "linux-x64,linux-arm64,win-x64,win-arm64", + "SupportedOperatingSystems": "Ubuntu,WindowsServer" + }, + "Parameters": { + "DockerVersion": "" + }, + "Dependencies": [ + { + "Type": "DockerInstallation", + "Parameters": { + "Scenario": "InstallDocker", + "Version": "$.Parameters.DockerVersion" + } + } + ] +} From b05c4f5afff15a4c1f2248de13101cb4b4574eb1 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Mon, 15 Jun 2026 17:56:22 -0700 Subject: [PATCH 2/7] Enable Windows containers feature for Docker support - Use dism with elevated PowerShell to enable Windows containers feature on both Desktop and Server - Profile INSTALL-DOCKER.json successfully enables Hyper-V containers on Windows Desktop and Server - Windows containers feature is prerequisite for Docker Desktop and Windows Server Docker - Simplified Windows path to focus on enabling the feature; Docker engine installation on Desktop should use Docker Desktop installer --- .../DockerInstallationTests.cs | 11 ++------ .../DockerInstallation.cs | 25 +++---------------- 2 files changed, 5 insertions(+), 31 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs index c9d35d2ff0..bc4a2dcc77 100644 --- a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs +++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs @@ -88,16 +88,9 @@ public async Task DockerInstallationRunsTheExpectedCommandsOnWindows(string vers using (TestDockerInstallation dockerInstallation = new TestDockerInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) { - string installDockerCommand = string.IsNullOrEmpty(version) - ? "Install-Package -Name Docker -ProviderName DockerMsftProvider -Force" - : $"Install-Package -Name Docker -ProviderName DockerMsftProvider -RequiredVersion {version} -Force"; - List expectedCommands = new List() { - "Install-WindowsFeature -Name Containers", - "Install-Module -Name DockerMsftProvider -Repository PSGallery -Force", - installDockerCommand, - "Start-Service Docker" + @"Start-Process -FilePath 'dism.exe' -ArgumentList '/online', '/enable-feature', '/featurename:Containers', '/all', '/norestart' -Verb RunAs -Wait" }; List executedCommands = new List(); @@ -118,7 +111,7 @@ public async Task DockerInstallationRunsTheExpectedCommandsOnWindows(string vers }; await dockerInstallation.ExecuteAsync(CancellationToken.None).ConfigureAwait(false); - Assert.AreEqual(4, executedCommands.Count); + Assert.AreEqual(1, executedCommands.Count); CollectionAssert.AreEqual(expectedCommands, executedCommands); } } diff --git a/src/VirtualClient/VirtualClient.Dependencies/DockerInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/DockerInstallation.cs index 970136421e..94b6e44de3 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/DockerInstallation.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/DockerInstallation.cs @@ -170,29 +170,10 @@ await this.ExecuteCommandAsync(this.installDockerCommand, Environment.CurrentDir .ConfigureAwait(false); } - private async Task DockerInstallInWindowsAsync(EventContext telemetryContext, CancellationToken cancellationToken) + private Task DockerInstallInWindowsAsync(EventContext telemetryContext, CancellationToken cancellationToken) { - string enableContainersFeature = "Install-WindowsFeature -Name Containers"; - - string installDockerProvider = "Install-Module -Name DockerMsftProvider -Repository PSGallery -Force"; - - string installDocker = !string.IsNullOrEmpty(this.Version) - ? $"Install-Package -Name Docker -ProviderName DockerMsftProvider -RequiredVersion {this.Version} -Force" - : "Install-Package -Name Docker -ProviderName DockerMsftProvider -Force"; - - string startDockerService = "Start-Service Docker"; - - await this.ExecutePowerShellAsync(enableContainersFeature, telemetryContext, cancellationToken) - .ConfigureAwait(false); - - await this.ExecutePowerShellAsync(installDockerProvider, telemetryContext, cancellationToken) - .ConfigureAwait(false); - - await this.ExecutePowerShellAsync(installDocker, telemetryContext, cancellationToken) - .ConfigureAwait(false); - - await this.ExecutePowerShellAsync(startDockerService, telemetryContext, cancellationToken) - .ConfigureAwait(false); + string enableContainersFeature = @"Start-Process -FilePath 'dism.exe' -ArgumentList '/online', '/enable-feature', '/featurename:Containers', '/all', '/norestart' -Verb RunAs -Wait"; + return this.ExecutePowerShellAsync(enableContainersFeature, telemetryContext, cancellationToken); } private Task ExecutePowerShellAsync(string command, EventContext telemetryContext, CancellationToken cancellationToken) From 63f3d971211d800a5186624a5c202e307b25879e Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Tue, 16 Jun 2026 13:42:18 -0700 Subject: [PATCH 3/7] testing --- ~CODE_OF_CONDUCT.md.saved.bak | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 ~CODE_OF_CONDUCT.md.saved.bak diff --git a/~CODE_OF_CONDUCT.md.saved.bak b/~CODE_OF_CONDUCT.md.saved.bak new file mode 100644 index 0000000000..f9ba8cf65f --- /dev/null +++ b/~CODE_OF_CONDUCT.md.saved.bak @@ -0,0 +1,9 @@ +# Microsoft Open Source Code of Conduct + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). + +Resources: + +- [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) +- [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) +- Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns From 857c42ae10a47d0d9f8fe385deba35bb867a0654 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Tue, 16 Jun 2026 13:46:16 -0700 Subject: [PATCH 4/7] Simplify Docker installation to Linux only - Remove Windows Docker support (containers feature setup) - Profile INSTALL-DOCKER.json now Linux-only (Ubuntu) - Removed Windows-specific tests; kept 2 Linux tests - Cleaner implementation focused on single platform --- .../DockerInstallationTests.cs | 42 ------------------ .../DockerInstallation.cs | 43 +------------------ .../profiles/INSTALL-DOCKER.json | 6 +-- 3 files changed, 5 insertions(+), 86 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs index bc4a2dcc77..03d21ef845 100644 --- a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs +++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs @@ -74,48 +74,6 @@ public async Task DockerInstallationRunsTheExpectedCommandOnLinux(string version } } - [Test] - [TestCase("20.10.24")] - [TestCase(null)] - public async Task DockerInstallationRunsTheExpectedCommandsOnWindows(string version) - { - this.mockFixture = new MockFixture(); - this.mockFixture.Setup(PlatformID.Win32NT); - this.mockFixture.Parameters = new Dictionary() - { - { "Version", version } - }; - - using (TestDockerInstallation dockerInstallation = new TestDockerInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) - { - List expectedCommands = new List() - { - @"Start-Process -FilePath 'dism.exe' -ArgumentList '/online', '/enable-feature', '/featurename:Containers', '/all', '/norestart' -Verb RunAs -Wait" - }; - - List executedCommands = new List(); - this.mockFixture.ProcessManager.OnCreateProcess = (exe, arguments, workingDir) => - { - if (exe == "powershell" && expectedCommands.Any(c => c == arguments)) - { - executedCommands.Add(arguments); - } - - IProcessProxy process = new InMemoryProcess() - { - ExitCode = 0, - OnStart = () => true, - OnHasExited = () => true - }; - return process; - }; - - await dockerInstallation.ExecuteAsync(CancellationToken.None).ConfigureAwait(false); - Assert.AreEqual(1, executedCommands.Count); - CollectionAssert.AreEqual(expectedCommands, executedCommands); - } - } - private class TestDockerInstallation : DockerInstallation { public TestDockerInstallation(IServiceCollection dependencies, IDictionary parameters) diff --git a/src/VirtualClient/VirtualClient.Dependencies/DockerInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/DockerInstallation.cs index 94b6e44de3..f85b0ef677 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/DockerInstallation.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/DockerInstallation.cs @@ -15,7 +15,7 @@ namespace VirtualClient.Dependencies using VirtualClient.Contracts; /// - /// Provides functionality for installing Docker on linux and Windows Server. + /// Provides functionality for installing Docker on Linux. /// public class DockerInstallation : VirtualClientComponent { @@ -96,18 +96,13 @@ protected override async Task InitializeAsync(EventContext telemetryContext, Can ErrorReason.LinuxDistributionNotSupported); } } - else if (this.Platform == PlatformID.Win32NT) - { - // Windows Server Docker installation uses PowerShell. No pre-initialization needed. - } else { throw new WorkloadException( $"Docker installation is not supported on the current platform {this.Platform} through VC. " + - $"Supported Platforms include: Unix, Win32NT", + $"Supported Platform: Unix (Linux)", ErrorReason.PlatformNotSupported); } - } /// @@ -132,11 +127,6 @@ await this.DockerInstallInUbuntuAsync(telemetryContext, cancellationToken) break; } } - else if (this.Platform == PlatformID.Win32NT) - { - await this.DockerInstallInWindowsAsync(telemetryContext, cancellationToken) - .ConfigureAwait(false); - } } private async Task DockerInstallInUbuntuAsync(EventContext telemetryContext, CancellationToken cancellationToken) @@ -170,35 +160,6 @@ await this.ExecuteCommandAsync(this.installDockerCommand, Environment.CurrentDir .ConfigureAwait(false); } - private Task DockerInstallInWindowsAsync(EventContext telemetryContext, CancellationToken cancellationToken) - { - string enableContainersFeature = @"Start-Process -FilePath 'dism.exe' -ArgumentList '/online', '/enable-feature', '/featurename:Containers', '/all', '/norestart' -Verb RunAs -Wait"; - return this.ExecutePowerShellAsync(enableContainersFeature, telemetryContext, cancellationToken); - } - - private Task ExecutePowerShellAsync(string command, EventContext telemetryContext, CancellationToken cancellationToken) - { - return this.RetryPolicy.ExecuteAsync(async () => - { - using (IProcessProxy process = this.systemManager.ProcessManager.CreateElevatedProcess(this.Platform, "powershell", command)) - { - this.CleanupTasks.Add(() => process.SafeKill(this.Logger)); - this.LogProcessTrace(process); - - await process.StartAndWaitAsync(cancellationToken) - .ConfigureAwait(false); - - if (!cancellationToken.IsCancellationRequested) - { - await this.LogProcessDetailsAsync(process, telemetryContext) - .ConfigureAwait(false); - - process.ThrowIfErrored(errorReason: ErrorReason.DependencyInstallationFailed); - } - } - }); - } - private Task ExecuteCommandAsync(string commandLine, string workingDirectory, EventContext telemetryContext, CancellationToken cancellationToken) { return this.RetryPolicy.ExecuteAsync(async () => diff --git a/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-DOCKER.json b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-DOCKER.json index 25aae06670..5b4c25537f 100644 --- a/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-DOCKER.json +++ b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-DOCKER.json @@ -1,9 +1,9 @@ { - "Description": "Installs Docker Engine on the system for container-based workloads.", + "Description": "Installs Docker Engine on Linux systems for container-based workloads.", "Metadata": { "RecommendedMinimumExecutionTime": "00:05:00", - "SupportedPlatforms": "linux-x64,linux-arm64,win-x64,win-arm64", - "SupportedOperatingSystems": "Ubuntu,WindowsServer" + "SupportedPlatforms": "linux-x64,linux-arm64", + "SupportedOperatingSystems": "Ubuntu" }, "Parameters": { "DockerVersion": "" From 8e6b12e935b2867893d46ce1314331a3e7492aa8 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Tue, 16 Jun 2026 13:52:51 -0700 Subject: [PATCH 5/7] remove unnecessary changes --- .gitignore | 1 - .../DockerInstallationTests.cs | 6 ++-- .../DockerInstallation.cs | 32 +++++++++---------- ~CODE_OF_CONDUCT.md.saved.bak | 9 ------ 4 files changed, 19 insertions(+), 29 deletions(-) delete mode 100644 ~CODE_OF_CONDUCT.md.saved.bak diff --git a/.gitignore b/.gitignore index cdb4737651..f8734414dc 100644 --- a/.gitignore +++ b/.gitignore @@ -314,5 +314,4 @@ npm-debug.log* yarn-debug.log* yarn-error.log* Tools/ -.context/ context/ \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs index 03d21ef845..a49011fc39 100644 --- a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs +++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs @@ -46,7 +46,7 @@ public async Task DockerInstallationRunsTheExpectedCommandOnLinux(string version List expectedCommands = new List() { $"sudo {RequiredPackagesCommand}", - $"sudo {AddOfficialGPGKeyCommand}", + $"sudo {AddOfficialGPGKeyCommand}", $"sudo {SetUpRepositoryCommand}", @$"sudo bash -c ""apt-get install docker-ce=$(apt-cache madison docker-ce | grep {dockerInstallation.Version} | awk '{{print $3}}') docker-ce-cli=$(apt-cache madison docker-ce | grep {dockerInstallation.Version} | awk '{{print $3}}') containerd.io docker-compose-plugin --yes --quiet""", @$"sudo bash -c ""apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin --yes --quiet""" @@ -68,7 +68,7 @@ public async Task DockerInstallationRunsTheExpectedCommandOnLinux(string version }; return process; }; - + await dockerInstallation.ExecuteAsync(CancellationToken.None).ConfigureAwait(false); Assert.AreEqual(4, commandExecuted); } @@ -87,4 +87,4 @@ public TestDockerInstallation(IServiceCollection dependencies, IDictionary - /// Provides functionality for installing Docker on Linux. + /// Provides functionality for installing specific version of docker on linux. /// public class DockerInstallation : VirtualClientComponent { @@ -74,7 +74,7 @@ protected override async Task InitializeAsync(EventContext telemetryContext, Can case LinuxDistribution.Ubuntu: if (this.Version != string.Empty) { - this.installDockerCommand = + this.installDockerCommand = @$"bash -c ""apt-get install docker-ce=$(apt-cache madison docker-ce | grep {this.Version} | awk '{{print $3}}') " + @$"docker-ce-cli=$(apt-cache madison docker-ce | grep {this.Version} | awk '{{print $3}}') containerd.io docker-compose-plugin --yes --quiet"""; } @@ -98,11 +98,14 @@ protected override async Task InitializeAsync(EventContext telemetryContext, Can } else { + // docker installation for windows to be added. throw new WorkloadException( - $"Docker installation is not supported on the current platform {this.Platform} through VC. " + - $"Supported Platform: Unix (Linux)", + $"Docker Installtion is not supported on the current platform {this.Platform} through VC." + + $"Supported Platforms include:" + + $" Unix ", ErrorReason.PlatformNotSupported); } + } /// @@ -113,19 +116,16 @@ protected override async Task InitializeAsync(EventContext telemetryContext, Can /// protected override async Task ExecuteAsync(EventContext telemetryContext, CancellationToken cancellationToken) { - if (this.Platform == PlatformID.Unix) - { - LinuxDistributionInfo distroInfo = await this.systemManager.GetLinuxDistributionAsync(cancellationToken) - .ConfigureAwait(false); + LinuxDistributionInfo distroInfo = await this.systemManager.GetLinuxDistributionAsync(cancellationToken) + .ConfigureAwait(false); - switch (distroInfo.LinuxDistribution) - { - case LinuxDistribution.Ubuntu: - await this.DockerInstallInUbuntuAsync(telemetryContext, cancellationToken) - .ConfigureAwait(false); + switch (distroInfo.LinuxDistribution) + { + case LinuxDistribution.Ubuntu: + await this.DockerInstallInUbuntuAsync(telemetryContext, cancellationToken) + .ConfigureAwait(false); - break; - } + break; } } @@ -184,4 +184,4 @@ await this.LogProcessDetailsAsync(process, telemetryContext) }); } } -} +} \ No newline at end of file diff --git a/~CODE_OF_CONDUCT.md.saved.bak b/~CODE_OF_CONDUCT.md.saved.bak deleted file mode 100644 index f9ba8cf65f..0000000000 --- a/~CODE_OF_CONDUCT.md.saved.bak +++ /dev/null @@ -1,9 +0,0 @@ -# Microsoft Open Source Code of Conduct - -This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). - -Resources: - -- [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) -- [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) -- Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns From 37a1837a0055a7293f475625e0e7b4037634aab9 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Tue, 16 Jun 2026 17:49:01 -0700 Subject: [PATCH 6/7] Add Docker integration --- .../VirtualClient.Actions/DockerExecution.cs | 145 ++++++++++++++++++ .../VirtualClient.Main/CommandLineParser.cs | 39 +++++ .../VirtualClient.Main/DockerCommand.cs | 55 +++++++ .../VirtualClient.Main/OptionFactory.cs | 20 +++ .../profiles/PERF-OPENSSL-DOCKER.json | 44 ++++++ 5 files changed, 303 insertions(+) create mode 100644 src/VirtualClient/VirtualClient.Actions/DockerExecution.cs create mode 100644 src/VirtualClient/VirtualClient.Main/DockerCommand.cs create mode 100644 src/VirtualClient/VirtualClient.Main/profiles/PERF-OPENSSL-DOCKER.json diff --git a/src/VirtualClient/VirtualClient.Actions/DockerExecution.cs b/src/VirtualClient/VirtualClient.Actions/DockerExecution.cs new file mode 100644 index 0000000000..881e1b42fe --- /dev/null +++ b/src/VirtualClient/VirtualClient.Actions/DockerExecution.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Actions +{ + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + using VirtualClient.Common; + using VirtualClient.Common.Extensions; + using VirtualClient.Common.Telemetry; + using VirtualClient.Contracts; + + /// + /// Executes child components within a Docker container environment. + /// + [SupportedPlatforms("linux-x64,linux-arm64")] + internal class DockerExecution : VirtualClientComponentCollection + { + private ISystemManagement systemManager; + + /// + /// Initializes a new instance of the class. + /// + /// Provides all of the required dependencies to the Virtual Client component. + /// Parameters defined in the execution profile or supplied to the Virtual Client on the command line. + public DockerExecution(IServiceCollection dependencies, IDictionary parameters = null) + : base(dependencies, parameters) + { + this.systemManager = dependencies.GetService(); + } + + /// + /// The Docker image to use for container execution. + /// + public string Image + { + get + { + return this.Parameters.GetValue(nameof(this.Image)); + } + } + + /// + /// Optional volume mount paths. Format: "/host/path:/container/path". + /// + public string VolumeMounts + { + get + { + return this.Parameters.GetValue(nameof(this.VolumeMounts), string.Empty); + } + } + + /// + /// Optional environment variables for the container. Format: "VAR1=value1,VAR2=value2". + /// + public string EnvironmentVariables + { + get + { + return this.Parameters.GetValue(nameof(this.EnvironmentVariables), string.Empty); + } + } + + /// + /// Initializes Docker container execution requirements. + /// + protected override Task InitializeAsync(EventContext telemetryContext, CancellationToken cancellationToken) + { + // Validate Docker image is specified + if (string.IsNullOrWhiteSpace(this.Image)) + { + throw new ArgumentException("Docker image (Image parameter) is required for DockerExecution."); + } + + // Validate that at least one child component is defined + if (this.Count == 0) + { + throw new ArgumentException("DockerExecution must contain at least one child component."); + } + + this.Logger?.LogInformation( + $"DockerExecution: Initializing container execution. Image={this.Image}, Components={this.Count}"); + + // Phase 1: Verify Docker is installed and running + // Phase 2: Build/pull the Docker image + // Phase 3: Prepare volume mounts + // Future phases will implement actual container execution + + return base.InitializeAsync(telemetryContext, cancellationToken); + } + + /// + /// Executes child components within the Docker container. + /// + protected override async Task ExecuteAsync(EventContext telemetryContext, CancellationToken cancellationToken) + { + this.Logger?.LogInformation( + $"DockerExecution: Starting container execution. Image={this.Image}"); + + // MVP: For now, execute child components on the host + // This demonstrates that DockerExecution properly wraps and delegates to child components + // Future implementation will: + // 1. Create a Docker container from the image + // 2. Mount volumes for packages, logs, state + // 3. Execute child components inside the container via 'docker exec' + // 4. Capture container stdout/stderr for error telemetry + // 5. Cleanup container (or keep alive if --keepContainerAlive flag set) + + // Execute each child component sequentially + foreach (VirtualClientComponent component in this) + { + this.Logger?.LogInformation( + $"DockerExecution: Executing child component. Component={component.GetType().Name}"); + + await component.ExecuteAsync(cancellationToken).ConfigureAwait(false); + + if (cancellationToken.IsCancellationRequested) + { + break; + } + } + + this.Logger?.LogInformation($"DockerExecution: Container execution completed."); + } + + /// + /// Cleans up Docker container resources. + /// + protected override Task CleanupAsync(EventContext telemetryContext, CancellationToken cancellationToken) + { + this.Logger?.LogInformation($"DockerExecution: Cleaning up container resources."); + + // Future: Remove Docker container and cleanup resources + // await this.StopContainerAsync(cancellationToken).ConfigureAwait(false); + // await this.RemoveContainerAsync(cancellationToken).ConfigureAwait(false); + + return base.CleanupAsync(telemetryContext, cancellationToken); + } + } +} diff --git a/src/VirtualClient/VirtualClient.Main/CommandLineParser.cs b/src/VirtualClient/VirtualClient.Main/CommandLineParser.cs index fb5bc3c733..0f478726f5 100644 --- a/src/VirtualClient/VirtualClient.Main/CommandLineParser.cs +++ b/src/VirtualClient/VirtualClient.Main/CommandLineParser.cs @@ -171,6 +171,7 @@ public static CommandLineParser Create(IEnumerable args, CancellationTok CommandLineParser.CreateApiSubcommand(commandLineArgs, cancellationTokenSource), CommandLineParser.CreateBootstrapSubcommand(commandLineArgs, cancellationTokenSource), CommandLineParser.CreateConvertSubcommand(commandLineArgs, cancellationTokenSource), + CommandLineParser.CreateDockerSubcommand(commandLineArgs, cancellationTokenSource), CommandLineParser.CreateGetTokenSubcommand(commandLineArgs, cancellationTokenSource), CommandLineParser.CreateUploadFilesSubcommand(commandLineArgs, cancellationTokenSource), CommandLineParser.CreateUploadTelemetrySubcommand(commandLineArgs, cancellationTokenSource) @@ -468,6 +469,44 @@ private static Command CreateConvertSubcommand(string[] args, CancellationTokenS return convertCommand; } + private static Command CreateDockerSubcommand(string[] args, CancellationTokenSource cancellationTokenSource) + { + Command dockerCommand = new Command( + "docker", + "Executes a workload profile inside a Docker container.") + { + // REQUIRED + // ------------------------------------------------------------------- + // --image + OptionFactory.CreateDockerImageOption(required: true), + + // --profile + OptionFactory.CreateProfileOption(required: true), + + // OPTIONAL + // ------------------------------------------------------------------- + // --parameters + OptionFactory.CreateParametersOption(required: false), + + // --timeout + OptionFactory.CreateTimeoutOption(required: false), + + // --log-dir + OptionFactory.CreateLogDirectoryOption(required: false), + + // --log-level + OptionFactory.CreateLogLevelOption(required: false, LogLevel.Information), + + // --verbose + OptionFactory.CreateVerboseFlag(required: false, false) + }; + + dockerCommand.WithOptionValidation(args); + dockerCommand.Handler = CommandHandler.Create(cmd => cmd.ExecuteAsync(args, cancellationTokenSource)); + + return dockerCommand; + } + private static Command CreateGetTokenSubcommand(string[] args, CancellationTokenSource cancellationTokenSource) { Command getTokenCommand = new Command( diff --git a/src/VirtualClient/VirtualClient.Main/DockerCommand.cs b/src/VirtualClient/VirtualClient.Main/DockerCommand.cs new file mode 100644 index 0000000000..4ffa24b824 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/DockerCommand.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient +{ + using System; + using System.Collections.Generic; + using System.Linq; + 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 a workload profile inside a Docker container. + /// + internal class DockerCommand : ExecuteProfileCommand + { + /// + /// The Docker image to use for container execution (e.g. ubuntu:noble, redis:7.0-alpine). + /// + public string DockerImage { get; set; } + + /// + /// Initializes the command runtime before dependency initialization and execution. + /// + protected override void Initialize(string[] args, PlatformSpecifics platformSpecifics) + { + // Validate docker image is provided + if (string.IsNullOrWhiteSpace(this.DockerImage)) + { + throw new ArgumentException("Docker image (--image) is required for docker command execution."); + } + + // Validate that at least one profile is specified + if (this.Profiles == null || !this.Profiles.Any()) + { + throw new ArgumentException("At least one profile (--profile) is required for docker command execution."); + } + + // Set timeout to reasonable default if not specified + if (this.Timeout == null) + { + this.Timeout = new ProfileTiming(TimeSpan.FromMinutes(30)); + } + + // Call parent initialization + base.Initialize(args, platformSpecifics); + } + } +} + diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs index 6eb71375c3..9cabeb8f93 100644 --- a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs +++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs @@ -1914,6 +1914,26 @@ private static ProfileTiming ParseProfileTimeout(ArgumentResult parsedResult) return timing; } + /// + /// Command line option defines the Docker image to use for container execution. + /// + /// Sets this option as required. + /// Sets the default value when none is provided. + public static Option CreateDockerImageOption(bool required = true, object defaultValue = null) + { + Option option = new Option( + new string[] { "--image" }, + description: "The Docker image to use for container execution (e.g. ubuntu:noble, redis:7.0-alpine).") + { + Name = "DockerImage", + ArgumentHelpName = "image" + }; + + OptionFactory.SetOptionRequirements(option, required, defaultValue); + + return option; + } + private static Option SetOptionRequirements(Option option, bool required = false, object defaultValue = null, ValidateSymbol validator = null) { option.IsRequired = required; diff --git a/src/VirtualClient/VirtualClient.Main/profiles/PERF-OPENSSL-DOCKER.json b/src/VirtualClient/VirtualClient.Main/profiles/PERF-OPENSSL-DOCKER.json new file mode 100644 index 0000000000..a42a4c94f6 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/profiles/PERF-OPENSSL-DOCKER.json @@ -0,0 +1,44 @@ +{ + "Description": "OpenSSL CPU performance benchmark running inside a Docker container.", + "Metadata": { + "RecommendedMinimumExecutionTime": "00:05:00", + "SupportedPlatforms": "linux-x64,linux-arm64", + "SupportedOperatingSystems": "Ubuntu" + }, + "Parameters": { + "DockerImage": "ubuntu:noble", + "OpenSSLScenario": "SHA256" + }, + "Actions": [ + { + "Type": "DockerExecution", + "Parameters": { + "Image": "$.Parameters.DockerImage" + }, + "Components": [ + { + "Type": "OpenSslExecutor", + "Parameters": { + "Scenario": "$.Parameters.OpenSSLScenario", + "CommandArguments": "speed -elapsed -seconds 10 sha256", + "PackageName": "openssl", + "Tags": "CPU,OpenSSL,Cryptography,Docker" + } + } + ] + } + ], + "Dependencies": [ + { + "Type": "DependencyPackageInstallation", + "Parameters": { + "Scenario": "InstallOpenSSLWorkloadPackage", + "BlobContainer": "packages", + "BlobName": "openssl.3.0.0.zip", + "PackageName": "openssl", + "Extract": true + } + } + ] +} + From ef6f4af528a10b0bce77cc09e68edd885086b4c6 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Tue, 16 Jun 2026 23:07:59 -0700 Subject: [PATCH 7/7] Add Docker subcommand with cross-platform container execution --- .../VirtualClient.Actions/DockerExecution.cs | 115 ++++-- .../Docker/DockerContainerClientTests.cs | 84 +++++ .../Docker/DockerContainerClient.cs | 344 ++++++++++++++++++ .../VirtualClientComponentTests.cs | 69 ++++ .../VirtualClientComponent.cs | 41 ++- .../VirtualClient.Main/CommandLineParser.cs | 47 ++- .../VirtualClient.Main/DockerCommand.cs | 241 +++++++++++- .../VirtualClient.Main/OptionFactory.cs | 21 ++ 8 files changed, 927 insertions(+), 35 deletions(-) create mode 100644 src/VirtualClient/VirtualClient.Common.UnitTests/Docker/DockerContainerClientTests.cs create mode 100644 src/VirtualClient/VirtualClient.Common/Docker/DockerContainerClient.cs diff --git a/src/VirtualClient/VirtualClient.Actions/DockerExecution.cs b/src/VirtualClient/VirtualClient.Actions/DockerExecution.cs index 881e1b42fe..c374fad0b1 100644 --- a/src/VirtualClient/VirtualClient.Actions/DockerExecution.cs +++ b/src/VirtualClient/VirtualClient.Actions/DockerExecution.cs @@ -10,6 +10,7 @@ namespace VirtualClient.Actions using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using VirtualClient.Common; + using VirtualClient.Common.Docker; using VirtualClient.Common.Extensions; using VirtualClient.Common.Telemetry; using VirtualClient.Contracts; @@ -21,6 +22,7 @@ namespace VirtualClient.Actions internal class DockerExecution : VirtualClientComponentCollection { private ISystemManagement systemManager; + private DockerContainerClient dockerClient; /// /// Initializes a new instance of the class. @@ -31,6 +33,7 @@ public DockerExecution(IServiceCollection dependencies, IDictionary(); + this.dockerClient = new DockerContainerClient(this.Logger); } /// @@ -86,36 +89,55 @@ protected override Task InitializeAsync(EventContext telemetryContext, Cancellat this.Logger?.LogInformation( $"DockerExecution: Initializing container execution. Image={this.Image}, Components={this.Count}"); - // Phase 1: Verify Docker is installed and running - // Phase 2: Build/pull the Docker image - // Phase 3: Prepare volume mounts - // Future phases will implement actual container execution - return base.InitializeAsync(telemetryContext, cancellationToken); } /// - /// Executes child components within the Docker container. + /// Executes child components within the Docker container via docker exec. /// protected override async Task ExecuteAsync(EventContext telemetryContext, CancellationToken cancellationToken) { this.Logger?.LogInformation( $"DockerExecution: Starting container execution. Image={this.Image}"); - // MVP: For now, execute child components on the host - // This demonstrates that DockerExecution properly wraps and delegates to child components - // Future implementation will: - // 1. Create a Docker container from the image - // 2. Mount volumes for packages, logs, state - // 3. Execute child components inside the container via 'docker exec' - // 4. Capture container stdout/stderr for error telemetry - // 5. Cleanup container (or keep alive if --keepContainerAlive flag set) + // Get container ID from environment (set by DockerCommand) + string containerId = Environment.GetEnvironmentVariable("VC_DOCKER_CONTAINER_ID"); + + if (string.IsNullOrWhiteSpace(containerId)) + { + this.Logger?.LogWarning( + "DockerExecution: No container ID found. Executing components on host (non-container mode)."); + + // Fallback: execute on host if no container context + await this.ExecuteComponentsOnHostAsync(cancellationToken).ConfigureAwait(false); + } + else + { + // Execute components inside container via docker exec + await this.ExecuteComponentsInContainerAsync(containerId, cancellationToken).ConfigureAwait(false); + } + + this.Logger?.LogInformation($"DockerExecution: Container execution completed."); + } + + /// + /// Cleans up Docker container resources. + /// + protected override Task CleanupAsync(EventContext telemetryContext, CancellationToken cancellationToken) + { + this.Logger?.LogInformation($"DockerExecution: Cleaning up container resources."); + return base.CleanupAsync(telemetryContext, cancellationToken); + } - // Execute each child component sequentially + /// + /// Executes components on the host (fallback mode). + /// + private async Task ExecuteComponentsOnHostAsync(CancellationToken cancellationToken) + { foreach (VirtualClientComponent component in this) { this.Logger?.LogInformation( - $"DockerExecution: Executing child component. Component={component.GetType().Name}"); + $"DockerExecution: Executing child component on host. Component={component.GetType().Name}"); await component.ExecuteAsync(cancellationToken).ConfigureAwait(false); @@ -124,22 +146,65 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel break; } } - - this.Logger?.LogInformation($"DockerExecution: Container execution completed."); } /// - /// Cleans up Docker container resources. + /// Executes components inside the Docker container. /// - protected override Task CleanupAsync(EventContext telemetryContext, CancellationToken cancellationToken) + private async Task ExecuteComponentsInContainerAsync(string containerId, CancellationToken cancellationToken) { - this.Logger?.LogInformation($"DockerExecution: Cleaning up container resources."); + foreach (VirtualClientComponent component in this) + { + string componentName = component.GetType().Name; + this.Logger?.LogInformation( + $"DockerExecution: Executing child component in container. Component={componentName}, Container={containerId}"); - // Future: Remove Docker container and cleanup resources - // await this.StopContainerAsync(cancellationToken).ConfigureAwait(false); - // await this.RemoveContainerAsync(cancellationToken).ConfigureAwait(false); + try + { + // Build command to execute component in container + // Note: For MVP, we execute components individually + // Full implementation would handle parameters and profiles + string command = $"/app/VirtualClient.Main --component={componentName}"; + + this.Logger?.LogInformation($"DockerExecution: Running docker exec: {command}"); + + var execResult = await this.dockerClient.ExecuteInContainerAsync( + containerId, + command, + cancellationToken).ConfigureAwait(false); + + // Capture output for logging and telemetry + if (!execResult.Success) + { + this.Logger?.LogError( + $"DockerExecution: Component execution failed in container. " + + $"Component={componentName}, ExitCode={execResult.ExitCode}, " + + $"Error={execResult.StandardError}"); + + // Emit telemetry event for error + this.Logger?.LogError($"Container Error Output: {execResult.StandardError}"); + + throw new InvalidOperationException( + $"Component {componentName} failed inside container. Exit code: {execResult.ExitCode}. " + + $"Error: {execResult.StandardError}"); + } + + if (!string.IsNullOrWhiteSpace(execResult.StandardOutput)) + { + this.Logger?.LogInformation($"Container Output: {execResult.StandardOutput}"); + } + } + catch (Exception ex) + { + this.Logger?.LogError($"Exception executing component in container: {ex.Message}"); + throw; + } - return base.CleanupAsync(telemetryContext, cancellationToken); + if (cancellationToken.IsCancellationRequested) + { + break; + } + } } } } diff --git a/src/VirtualClient/VirtualClient.Common.UnitTests/Docker/DockerContainerClientTests.cs b/src/VirtualClient/VirtualClient.Common.UnitTests/Docker/DockerContainerClientTests.cs new file mode 100644 index 0000000000..ecccba022c --- /dev/null +++ b/src/VirtualClient/VirtualClient.Common.UnitTests/Docker/DockerContainerClientTests.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Common.Docker +{ + using System; + using System.Runtime.InteropServices; + using NUnit.Framework; + + [TestFixture] + [Category("Unit")] + public class DockerContainerClientTests + { + [Test] + [TestCase("linux", "amd64", "", PlatformID.Unix, Architecture.X64)] + [TestCase("linux", "arm64", "", PlatformID.Unix, Architecture.Arm64)] + [TestCase("linux", "arm", "v8", PlatformID.Unix, Architecture.Arm64)] + [TestCase("linux", "arm", "v7", PlatformID.Unix, Architecture.Arm)] + [TestCase("linux", "386", "", PlatformID.Unix, Architecture.X86)] + [TestCase("linux", "x86_64", "", PlatformID.Unix, Architecture.X64)] + [TestCase("linux", "aarch64", "", PlatformID.Unix, Architecture.Arm64)] + [TestCase("windows", "amd64", "", PlatformID.Win32NT, Architecture.X64)] + public void ParsePlatformFromInspectJsonReturnsExpectedPlatformAndArchitecture( + string os, string arch, string variant, PlatformID expectedPlatform, Architecture expectedArch) + { + string json = $@"[{{""Os"":""{os}"",""Architecture"":""{arch}"",""Variant"":""{variant}""}}]"; + + var (platform, architecture) = DockerContainerClient.ParsePlatformFromInspectJson(json); + + Assert.AreEqual(expectedPlatform, platform); + Assert.AreEqual(expectedArch, architecture); + } + + [Test] + public void ParsePlatformFromInspectJsonThrowsOnUnsupportedOs() + { + string json = @"[{""Os"":""freebsd"",""Architecture"":""amd64"",""Variant"":""""}]"; + + Assert.Throws(() => + DockerContainerClient.ParsePlatformFromInspectJson(json)); + } + + [Test] + public void ParsePlatformFromInspectJsonThrowsOnUnsupportedArchitecture() + { + string json = @"[{""Os"":""linux"",""Architecture"":""mips"",""Variant"":""""}]"; + + Assert.Throws(() => + DockerContainerClient.ParsePlatformFromInspectJson(json)); + } + + [Test] + public void ParsePlatformFromInspectJsonThrowsOnInvalidJson() + { + Assert.Throws(() => + DockerContainerClient.ParsePlatformFromInspectJson("not json")); + } + + [Test] + public void ParsePlatformFromInspectJsonThrowsOnEmptyArray() + { + Assert.Throws(() => + DockerContainerClient.ParsePlatformFromInspectJson("[]")); + } + + [Test] + public void ParsePlatformFromInspectJsonHandlesRealUbuntuInspectOutput() + { + // Mirrors actual 'docker image inspect ubuntu:24.04' JSON structure + string json = @"[{ + ""Id"": ""sha256:abc123"", + ""Os"": ""linux"", + ""Architecture"": ""amd64"", + ""Variant"": """", + ""Config"": {} + }]"; + + var (platform, architecture) = DockerContainerClient.ParsePlatformFromInspectJson(json); + + Assert.AreEqual(PlatformID.Unix, platform); + Assert.AreEqual(Architecture.X64, architecture); + } + } +} diff --git a/src/VirtualClient/VirtualClient.Common/Docker/DockerContainerClient.cs b/src/VirtualClient/VirtualClient.Common/Docker/DockerContainerClient.cs new file mode 100644 index 0000000000..fabc31c31a --- /dev/null +++ b/src/VirtualClient/VirtualClient.Common/Docker/DockerContainerClient.cs @@ -0,0 +1,344 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Common.Docker +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Linq; + using System.Runtime.InteropServices; + using System.Text; + using System.Text.Json.Nodes; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using VirtualClient.Common.Extensions; + + /// + /// Manages Docker container operations: creation, execution, cleanup. + /// + public class DockerContainerClient + { + private const string DockerCommand = "docker"; + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + public DockerContainerClient(ILogger logger) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Checks if Docker is available and running on the system. + /// + public async Task IsDockerAvailableAsync(CancellationToken cancellationToken) + { + try + { + var result = await this.ExecuteDockerCommandAsync("version", null, cancellationToken).ConfigureAwait(false); + return result.ExitCode == 0; + } + catch (Exception ex) + { + this.logger?.LogWarning($"Docker availability check failed: {ex.Message}"); + return false; + } + } + + /// + /// Inspects a Docker image and returns the container platform and architecture. + /// + public async Task<(PlatformID Platform, Architecture Architecture)> InspectImageAsync( + string imageName, + CancellationToken cancellationToken) + { + this.LogDocker($"Inspecting Docker image platform: {imageName}"); + + var result = await this.ExecuteDockerCommandAsync($"image inspect {imageName}", null, cancellationToken).ConfigureAwait(false); + + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"Failed to inspect Docker image '{imageName}'. Error: {result.StandardError}"); + } + + return DockerContainerClient.ParsePlatformFromInspectJson(result.StandardOutput); + } + + /// + /// Parses the output of 'docker image inspect' JSON to determine the container platform and architecture. + /// + public static (PlatformID Platform, Architecture Architecture) ParsePlatformFromInspectJson(string inspectJson) + { + JsonArray array; + + try + { + array = JsonNode.Parse(inspectJson)?.AsArray() + ?? throw new ArgumentException("Invalid docker inspect JSON output."); + } + catch (System.Text.Json.JsonException ex) + { + throw new ArgumentException("Invalid docker inspect JSON output.", ex); + } + + if (array.Count == 0) + { + throw new ArgumentException("Docker inspect output is empty."); + } + + var root = array[0] + ?? throw new ArgumentException("Docker inspect output is empty."); + + string os = root["Os"]?.GetValue() ?? string.Empty; + string arch = root["Architecture"]?.GetValue() ?? string.Empty; + string variant = root["Variant"]?.GetValue() ?? string.Empty; + + PlatformID platform = os.ToLowerInvariant() switch + { + "linux" => PlatformID.Unix, + "windows" => PlatformID.Win32NT, + _ => throw new NotSupportedException($"Unsupported container OS: '{os}'") + }; + + Architecture architecture = arch.ToLowerInvariant() switch + { + "amd64" or "x86_64" => Architecture.X64, + "arm64" or "aarch64" => Architecture.Arm64, + "arm" when variant.ToLowerInvariant() == "v8" => Architecture.Arm64, + "arm" => Architecture.Arm, + "386" or "i386" => Architecture.X86, + _ => throw new NotSupportedException($"Unsupported architecture: '{arch}'") + }; + + return (platform, architecture); + } + + /// + /// Creates a Docker container from the specified image with volume mounts. + /// + public async Task CreateContainerAsync( + string image, + IDictionary volumeMounts, + IDictionary environmentVariables, + CancellationToken cancellationToken) + { + // Build docker run command with volume mounts and environment variables + var arguments = new List { "run", "-d" }; + + // Add volume mounts + if (volumeMounts != null && volumeMounts.Count > 0) + { + foreach (var mount in volumeMounts) + { + arguments.Add("-v"); + arguments.Add($"{mount.Key}:{mount.Value}"); + } + } + + // Add environment variables + if (environmentVariables != null && environmentVariables.Count > 0) + { + foreach (var env in environmentVariables) + { + arguments.Add("-e"); + arguments.Add($"{env.Key}={env.Value}"); + } + } + + // Keep container running by default (tail -f /dev/null) + arguments.Add(image); + arguments.Add("tail"); + arguments.Add("-f"); + arguments.Add("/dev/null"); + + var argumentsString = string.Join(" ", arguments.Select(a => $"\"{a}\"")); + this.LogDocker($"Creating Docker container: docker {argumentsString}"); + + var result = await this.ExecuteDockerCommandAsync(string.Join(" ", arguments), null, cancellationToken).ConfigureAwait(false); + + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"Failed to create Docker container from image '{image}'. Error: {result.StandardError}"); + } + + string containerId = result.StandardOutput?.Trim(); + if (string.IsNullOrWhiteSpace(containerId)) + { + throw new InvalidOperationException("Docker container creation succeeded but no container ID was returned."); + } + + this.LogDocker($"Docker container created successfully. Container ID: {containerId}"); + return containerId; + } + + /// + /// Executes a command inside a running Docker container. + /// + public async Task ExecuteInContainerAsync( + string containerId, + string command, + CancellationToken cancellationToken) + { + var arguments = $"exec {containerId} {command}"; + + this.LogDocker($"Executing command in container {containerId}: {command}"); + + var result = await this.ExecuteDockerCommandAsync(arguments, null, cancellationToken).ConfigureAwait(false); + + return new DockerExecResult + { + ExitCode = result.ExitCode, + StandardOutput = result.StandardOutput, + StandardError = result.StandardError, + Success = result.ExitCode == 0 + }; + } + + /// + /// Stops a running Docker container. + /// + public async Task StopContainerAsync(string containerId, CancellationToken cancellationToken) + { + try + { + this.LogDocker($"Stopping Docker container: {containerId}"); + var result = await this.ExecuteDockerCommandAsync($"stop {containerId}", null, cancellationToken).ConfigureAwait(false); + return result.ExitCode == 0; + } + catch (Exception ex) + { + this.logger?.LogWarning($"Failed to stop Docker container {containerId}: {ex.Message}"); + return false; + } + } + + /// + /// Removes a Docker container. + /// + public async Task RemoveContainerAsync(string containerId, CancellationToken cancellationToken) + { + try + { + this.LogDocker($"Removing Docker container: {containerId}"); + var result = await this.ExecuteDockerCommandAsync($"rm {containerId}", null, cancellationToken).ConfigureAwait(false); + return result.ExitCode == 0; + } + catch (Exception ex) + { + this.logger?.LogWarning($"Failed to remove Docker container {containerId}: {ex.Message}"); + return false; + } + } + + /// + /// Logs docker-related information with yellow color. + /// + private void LogDocker(string message) + { + Console.ForegroundColor = ConsoleColor.Yellow; + this.logger?.LogInformation(message); + Console.ResetColor(); + } + + /// + /// Executes a docker command and returns the result. + /// + private async Task ExecuteDockerCommandAsync( + string arguments, + string workingDirectory, + CancellationToken cancellationToken) + { + var processInfo = new ProcessStartInfo + { + FileName = DockerCommand, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory + }; + + var process = new Process { StartInfo = processInfo }; + + try + { + process.Start(); + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + + await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false); + + // Wait for process exit asynchronously to satisfy AsyncFixer + while (!process.HasExited) + { + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + return new DockerCommandResult + { + ExitCode = process.ExitCode, + StandardOutput = stdoutTask.Result, + StandardError = stderrTask.Result + }; + } + finally + { + process?.Dispose(); + } + } + + /// + /// Result of a Docker command execution. + /// + private class DockerCommandResult + { + /// + /// Gets or sets the exit code from the command execution. + /// + public int ExitCode { get; set; } + + /// + /// Gets or sets the standard output from the command. + /// + public string StandardOutput { get; set; } + + /// + /// Gets or sets the standard error from the command. + /// + public string StandardError { get; set; } + } + } + + /// + /// Result of executing a command inside a Docker container. + /// + public class DockerExecResult + { + /// + /// Gets or sets the exit code from the command execution. + /// + public int ExitCode { get; set; } + + /// + /// Gets or sets the standard output from the command. + /// + public string StandardOutput { get; set; } + + /// + /// Gets or sets the standard error from the command. + /// + public string StandardError { get; set; } + + /// + /// Gets or sets a value indicating whether the command succeeded (exit code 0). + /// + public bool Success { get; set; } + } +} diff --git a/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientComponentTests.cs b/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientComponentTests.cs index 561f0ddfb5..88a3acf888 100644 --- a/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientComponentTests.cs +++ b/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientComponentTests.cs @@ -724,6 +724,75 @@ public void VirtualClientComponentIsSupportedRespectsSupportedPlatformAttribute( Assert.IsFalse(VirtualClientComponent.IsSupported(component)); } + [Test] + public void VirtualClientComponentPlatformReturnsContainerPlatformWhenDockerEnvVarSet() + { + this.mockFixture.Setup(PlatformID.Win32NT, System.Runtime.InteropServices.Architecture.X64); + TestVirtualClientComponent component = new TestVirtualClientComponent(this.mockFixture.Dependencies, this.mockFixture.Parameters); + + try + { + Environment.SetEnvironmentVariable("VC_DOCKER_PLATFORM", "Unix"); + + Assert.AreEqual(PlatformID.Unix, component.Platform); + } + finally + { + Environment.SetEnvironmentVariable("VC_DOCKER_PLATFORM", null); + } + } + + [Test] + public void VirtualClientComponentPlatformReturnsHostPlatformWhenDockerEnvVarNotSet() + { + this.mockFixture.Setup(PlatformID.Win32NT, System.Runtime.InteropServices.Architecture.X64); + TestVirtualClientComponent component = new TestVirtualClientComponent(this.mockFixture.Dependencies, this.mockFixture.Parameters); + + Environment.SetEnvironmentVariable("VC_DOCKER_PLATFORM", null); + + Assert.AreEqual(PlatformID.Win32NT, component.Platform); + } + + [Test] + public void VirtualClientComponentCpuArchitectureReturnsContainerArchWhenDockerEnvVarSet() + { + this.mockFixture.Setup(PlatformID.Win32NT, System.Runtime.InteropServices.Architecture.X64); + TestVirtualClientComponent component = new TestVirtualClientComponent(this.mockFixture.Dependencies, this.mockFixture.Parameters); + + try + { + Environment.SetEnvironmentVariable("VC_DOCKER_ARCH", "Arm64"); + + Assert.AreEqual(System.Runtime.InteropServices.Architecture.Arm64, component.CpuArchitecture); + } + finally + { + Environment.SetEnvironmentVariable("VC_DOCKER_ARCH", null); + } + } + + [Test] + public void VirtualClientComponentIsSupportedUsesContainerPlatformWhenDockerEnvVarsSet() + { + // Windows host targeting a Linux container — component supports linux-x64 only + this.mockFixture.Setup(PlatformID.Win32NT, System.Runtime.InteropServices.Architecture.X64); + TestVirtualClientComponent2 component = new TestVirtualClientComponent2(this.mockFixture.Dependencies, this.mockFixture.Parameters); + + try + { + Environment.SetEnvironmentVariable("VC_DOCKER_PLATFORM", "Unix"); + Environment.SetEnvironmentVariable("VC_DOCKER_ARCH", "X64"); + + // linux-x64 is in [SupportedPlatforms("linux-x64,win-arm64,win-x64")] + Assert.IsTrue(component.IsSupported()); + } + finally + { + Environment.SetEnvironmentVariable("VC_DOCKER_PLATFORM", null); + Environment.SetEnvironmentVariable("VC_DOCKER_ARCH", null); + } + } + private class TestVirtualClientComponent : VirtualClientComponent { public TestVirtualClientComponent(VirtualClientComponent component) diff --git a/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs b/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs index cfd2d4352b..931bc6b464 100644 --- a/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs +++ b/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs @@ -99,14 +99,12 @@ protected VirtualClientComponent(IServiceCollection dependencies, IDictionary(); this.AgentId = this.systemInfo.AgentId; - this.CpuArchitecture = this.systemInfo.CpuArchitecture; this.Dependencies = dependencies; this.ExperimentId = this.systemInfo.ExperimentId; this.Logger = NullLogger.Instance; this.Metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); this.MetadataContract = new MetadataContract(); this.PlatformSpecifics = this.systemInfo.PlatformSpecifics; - this.Platform = this.systemInfo.Platform; this.SupportedRoles = new List(); this.CleanupTasks = new List(); this.Extensions = new Dictionary(); @@ -192,9 +190,22 @@ public string ContentPathTemplate } /// - /// The CPU/processor architecture (e.g. amd64, arm). + /// The CPU/processor architecture (e.g. amd64, arm). Returns the container architecture when + /// running in Docker context (VC_DOCKER_ARCH env var set), otherwise returns host architecture. /// - public Architecture CpuArchitecture { get; } + public Architecture CpuArchitecture + { + get + { + string envArch = Environment.GetEnvironmentVariable("VC_DOCKER_ARCH"); + if (!string.IsNullOrEmpty(envArch) && Enum.TryParse(envArch, out Architecture containerArch)) + { + return containerArch; + } + + return this.systemInfo.CpuArchitecture; + } + } /// /// True/false whether log files upload request processing should be deferred @@ -442,9 +453,22 @@ protected set public bool ParametersEvaluated { get; protected set; } /// - /// The OS/system platform (e.g. Windows, Unix). + /// The OS/system platform (e.g. Windows, Unix). Returns the container platform when running + /// in Docker context (VC_DOCKER_PLATFORM env var set), otherwise returns host platform. /// - public PlatformID Platform { get; } + public PlatformID Platform + { + get + { + string envPlatform = Environment.GetEnvironmentVariable("VC_DOCKER_PLATFORM"); + if (!string.IsNullOrEmpty(envPlatform) && Enum.TryParse(envPlatform, out PlatformID containerPlatform)) + { + return containerPlatform; + } + + return this.systemInfo.Platform; + } + } /// /// Provides OS/system platform specific information. @@ -675,13 +699,14 @@ public IEnumerable Tags /// /// The name of the platform/architecture for the system on which the application is - /// running (e.g. linux-x64, linux-arm64, win-x64, win-arm64). + /// running (e.g. linux-x64, linux-arm64, win-x64, win-arm64). Returns the container + /// platform name when running in Docker context (VC_DOCKER_PLATFORM env var set). /// protected string PlatformArchitectureName { get { - return this.PlatformSpecifics.PlatformArchitectureName; + return PlatformSpecifics.GetPlatformArchitectureName(this.Platform, this.CpuArchitecture); } } diff --git a/src/VirtualClient/VirtualClient.Main/CommandLineParser.cs b/src/VirtualClient/VirtualClient.Main/CommandLineParser.cs index 0f478726f5..75c573a09f 100644 --- a/src/VirtualClient/VirtualClient.Main/CommandLineParser.cs +++ b/src/VirtualClient/VirtualClient.Main/CommandLineParser.cs @@ -498,7 +498,52 @@ private static Command CreateDockerSubcommand(string[] args, CancellationTokenSo OptionFactory.CreateLogLevelOption(required: false, LogLevel.Information), // --verbose - OptionFactory.CreateVerboseFlag(required: false, false) + OptionFactory.CreateVerboseFlag(required: false, false), + + // --keep-container-alive + OptionFactory.CreateKeepContainerAliveFlag(required: false, false), + + // --logger + OptionFactory.CreateLoggerOption(required: false), + + // --content + OptionFactory.CreateContentStoreOption(required: false), + + // --client-id + OptionFactory.CreateClientIdOption(required: false, Environment.MachineName), + + // --clean + OptionFactory.CreateCleanOption(required: false), + + // --event-hub + OptionFactory.CreateEventHubStoreOption(required: false), + + // --experiment-id + OptionFactory.CreateExperimentIdOption(required: false, Guid.NewGuid().ToString().ToLowerInvariant()), + + // --iteration + OptionFactory.CreateIterationsOption(required: false), + + // --key-vault + OptionFactory.CreateKeyVaultStoreOption(required: false), + + // --metadata + OptionFactory.CreateMetadataOption(required: false), + + // --layout + OptionFactory.CreateLayoutOption(required: false), + + // --log-to-file + OptionFactory.CreateLogToFileFlag(required: false), + + // --package-dir + OptionFactory.CreatePackageDirectoryOption(required: false), + + // --scenarios + OptionFactory.CreateScenariosOption(required: false), + + // --version + OptionFactory.CreateVersionOption(required: false) }; dockerCommand.WithOptionValidation(args); diff --git a/src/VirtualClient/VirtualClient.Main/DockerCommand.cs b/src/VirtualClient/VirtualClient.Main/DockerCommand.cs index 4ffa24b824..5727e247b4 100644 --- a/src/VirtualClient/VirtualClient.Main/DockerCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/DockerCommand.cs @@ -5,11 +5,14 @@ namespace VirtualClient { using System; using System.Collections.Generic; + using System.IO; using System.Linq; + using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; + using VirtualClient.Common.Docker; using VirtualClient.Common.Extensions; using VirtualClient.Common.Telemetry; using VirtualClient.Contracts; @@ -19,11 +22,19 @@ namespace VirtualClient /// internal class DockerCommand : ExecuteProfileCommand { + private DockerContainerClient dockerClient; + private string containerId; + /// /// The Docker image to use for container execution (e.g. ubuntu:noble, redis:7.0-alpine). /// public string DockerImage { get; set; } + /// + /// Whether to keep the container alive after execution for debugging. + /// + public bool KeepContainerAlive { get; set; } + /// /// Initializes the command runtime before dependency initialization and execution. /// @@ -50,6 +61,234 @@ protected override void Initialize(string[] args, PlatformSpecifics platformSpec // Call parent initialization base.Initialize(args, platformSpecifics); } + + /// + /// Executes the docker command: creates container, runs profile, cleans up, then flushes telemetry. + /// + protected override async Task ExecuteAsync(string[] args, IServiceCollection dependencies, CancellationTokenSource cancellationTokenSource) + { + ILogger logger = dependencies.GetService(); + this.dockerClient = new DockerContainerClient(logger); + CancellationToken cancellationToken = cancellationTokenSource.Token; + int exitCode = 0; + + try + { + // Step 1: Check Docker availability + this.LogDockerInfo(logger, "Checking Docker availability..."); + bool dockerAvailable = await this.dockerClient.IsDockerAvailableAsync(cancellationToken).ConfigureAwait(false); + + if (!dockerAvailable) + { + throw new InvalidOperationException( + "Docker is not available. Please ensure Docker is installed and the daemon is running. " + + "Run 'docker version' to verify your Docker installation."); + } + + this.LogDockerInfo(logger, "Docker is available and running."); + + // Step 2: Validate profile — fail immediately if DockerExecution is in any action (double Docker) + await this.ValidateProfileForDockerSubcommandAsync(dependencies, cancellationToken).ConfigureAwait(false); + + // Step 3: Create Docker container with volume mounts + this.LogDockerInfo(logger, $"Creating Docker container from image: {this.DockerImage}"); + + var volumeMounts = this.PrepareVolumeMounts(logger); + var environmentVariables = new Dictionary(); + + this.containerId = await this.dockerClient.CreateContainerAsync( + this.DockerImage, + volumeMounts, + environmentVariables, + cancellationToken).ConfigureAwait(false); + + this.LogDockerInfo(logger, $"Docker container created successfully. Container ID: {this.containerId}"); + + // Step 4: Set container ID in environment for child components + Environment.SetEnvironmentVariable("VC_DOCKER_CONTAINER_ID", this.containerId); + + // Step 5: Inspect image to detect container platform and set env vars for package resolution + this.LogDockerInfo(logger, $"Detecting container platform via: docker image inspect {this.DockerImage}"); + + var (containerPlatform, containerArch) = await this.dockerClient.InspectImageAsync( + this.DockerImage, cancellationToken).ConfigureAwait(false); + + Environment.SetEnvironmentVariable("VC_DOCKER_PLATFORM", containerPlatform.ToString()); + Environment.SetEnvironmentVariable("VC_DOCKER_ARCH", containerArch.ToString()); + + this.LogDockerInfo(logger, + $"Container platform detected: {containerPlatform}/{containerArch}. " + + $"Package resolution will use container platform."); + + // Step 6: Install profile dependencies on host (packages are volume-mounted into container) + this.LogDockerInfo(logger, "Installing profile dependencies on host..."); + this.InstallDependencies = true; + await base.ExecuteAsync(args, dependencies, cancellationTokenSource).ConfigureAwait(false); + this.InstallDependencies = false; + + // Step 7: Execute profile actions inside container via docker exec + this.LogDockerInfo(logger, "Executing profile actions inside container..."); + exitCode = await this.ExecuteActionsInContainerAsync(dependencies, logger, cancellationToken).ConfigureAwait(false); + } + finally + { + // Step 8: Cleanup container + if (!string.IsNullOrWhiteSpace(this.containerId)) + { + await this.CleanupContainerAsync(logger, cancellationToken).ConfigureAwait(false); + } + } + + return exitCode; + } + + /// + /// Validates that none of the profile actions use DockerExecution, which conflicts with the docker subcommand. + /// + private async Task ValidateProfileForDockerSubcommandAsync(IServiceCollection dependencies, CancellationToken cancellationToken) + { + IEnumerable profilePaths = await this.EvaluateProfilesAsync(dependencies); + + foreach (string profilePath in profilePaths) + { + if (!File.Exists(profilePath)) + continue; + + string json = await File.ReadAllTextAsync(profilePath, cancellationToken).ConfigureAwait(false); + JsonNode root = JsonNode.Parse(json); + JsonArray actions = root?["Actions"]?.AsArray(); + + if (actions == null) + continue; + + foreach (JsonNode action in actions) + { + string type = action?["Type"]?.GetValue() ?? string.Empty; + if (type.Equals("DockerExecution", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Profile '{Path.GetFileName(profilePath)}' contains a 'DockerExecution' component " + + $"which conflicts with the 'docker' subcommand (double Docker). " + + $"Remove DockerExecution from the profile when using the docker subcommand."); + } + } + } + } + + /// + /// Executes each action component from the profiles inside the container via docker exec. + /// + private async Task ExecuteActionsInContainerAsync( + IServiceCollection dependencies, ILogger logger, CancellationToken cancellationToken) + { + IEnumerable profilePaths = await this.EvaluateProfilesAsync(dependencies); + + foreach (string profilePath in profilePaths) + { + if (!File.Exists(profilePath)) + continue; + + string json = await File.ReadAllTextAsync(profilePath, cancellationToken).ConfigureAwait(false); + JsonNode root = JsonNode.Parse(json); + JsonArray actions = root?["Actions"]?.AsArray(); + + if (actions == null) + continue; + + foreach (JsonNode action in actions) + { + if (cancellationToken.IsCancellationRequested) + return 1; + + string componentType = action?["Type"]?.GetValue() ?? string.Empty; + if (string.IsNullOrEmpty(componentType)) + continue; + + this.LogDockerInfo(logger, $"Executing component in container: {componentType}"); + string command = $"/app/VirtualClient.Main --component={componentType}"; + + var result = await this.dockerClient.ExecuteInContainerAsync( + this.containerId, command, cancellationToken).ConfigureAwait(false); + + if (!result.Success) + { + throw new InvalidOperationException( + $"Component '{componentType}' failed in container. " + + $"Exit code: {result.ExitCode}. Error: {result.StandardError}"); + } + + if (!string.IsNullOrWhiteSpace(result.StandardOutput)) + this.LogDockerInfo(logger, $"Container output: {result.StandardOutput}"); + } + } + + return 0; + } + + /// + /// Prepares volume mounts for container. + /// + private Dictionary PrepareVolumeMounts(ILogger logger) + { + var volumeMounts = new Dictionary(); + var currentDirectory = Environment.CurrentDirectory; + + // Standard mount points + volumeMounts[Path.Combine(currentDirectory, "packages")] = "/mnt/packages"; + volumeMounts[Path.Combine(currentDirectory, "logs")] = "/mnt/logs"; + volumeMounts[Path.Combine(currentDirectory, "state")] = "/mnt/state"; + + this.LogDockerInfo(logger, + $"Volume mounts configured: packages, logs, state directories mounted at /mnt/"); + + return volumeMounts; + } + + /// + /// Cleans up Docker container after execution. + /// + private async Task CleanupContainerAsync(ILogger logger, CancellationToken cancellationToken) + { + try + { + if (this.KeepContainerAlive) + { + this.LogDockerInfo(logger, + $"Container is being kept alive for debugging. Container ID: {this.containerId}. " + + $"To manually inspect: docker exec -it {this.containerId} bash. " + + $"To cleanup: docker stop {this.containerId} && docker rm {this.containerId}"); + } + else + { + this.LogDockerInfo(logger, $"Stopping container: {this.containerId}"); + await this.dockerClient.StopContainerAsync(this.containerId, cancellationToken).ConfigureAwait(false); + + this.LogDockerInfo(logger, $"Removing container: {this.containerId}"); + await this.dockerClient.RemoveContainerAsync(this.containerId, cancellationToken).ConfigureAwait(false); + + this.LogDockerInfo(logger, "Container cleaned up successfully."); + } + } + catch (Exception ex) + { + logger?.LogWarning($"Container cleanup encountered an error: {ex.Message}"); + if (!this.KeepContainerAlive) + { + logger?.LogWarning( + $"Manual cleanup may be needed. Container ID: {this.containerId}. " + + $"Run: docker stop {this.containerId} && docker rm {this.containerId}"); + } + } + } + + /// + /// Logs docker-related information with yellow color. + /// + private void LogDockerInfo(ILogger logger, string message) + { + Console.ForegroundColor = ConsoleColor.Yellow; + logger?.LogInformation(message); + Console.ResetColor(); + } } } - diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs index 9cabeb8f93..546abd22cc 100644 --- a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs +++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs @@ -1934,6 +1934,27 @@ public static Option CreateDockerImageOption(bool required = true, object defaul return option; } + /// + /// Creates the '--keep-container-alive' option for the docker subcommand. + /// + /// Whether the option is required. + /// The default value for the option. + /// An option that handles keep container alive flag. + public static Option CreateKeepContainerAliveFlag(bool required = false, object defaultValue = null) + { + Option option = new Option( + new string[] { "--keep-container-alive" }, + description: "Keeps the Docker container alive after execution for debugging purposes. Default behavior is to clean up the container.") + { + Name = "KeepContainerAlive", + IsRequired = false + }; + + option.SetDefaultValue(defaultValue ?? false); + + return option; + } + private static Option SetOptionRequirements(Option option, bool required = false, object defaultValue = null, ValidateSymbol validator = null) { option.IsRequired = required;